spiceflow 1.18.0 → 1.19.0-rsc.1

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 (558) hide show
  1. package/README.md +1839 -1208
  2. package/dist/_node-server.d.ts +7 -3
  3. package/dist/_node-server.d.ts.map +1 -1
  4. package/dist/_node-server.js +122 -52
  5. package/dist/_node-server.js.map +1 -1
  6. package/dist/_node-server.test.d.ts +2 -0
  7. package/dist/_node-server.test.d.ts.map +1 -0
  8. package/dist/_node-server.test.js +48 -0
  9. package/dist/_node-server.test.js.map +1 -0
  10. package/dist/action-context.d.ts +24 -0
  11. package/dist/action-context.d.ts.map +1 -0
  12. package/dist/action-context.js +33 -0
  13. package/dist/action-context.js.map +1 -0
  14. package/dist/any-spiceflow-types.test.d.ts +2 -0
  15. package/dist/any-spiceflow-types.test.d.ts.map +1 -0
  16. package/dist/any-spiceflow-types.test.js +202 -0
  17. package/dist/any-spiceflow-types.test.js.map +1 -0
  18. package/dist/base-path.d.ts +2 -0
  19. package/dist/base-path.d.ts.map +1 -0
  20. package/dist/base-path.js +22 -0
  21. package/dist/base-path.js.map +1 -0
  22. package/dist/client/fetch.d.ts +17 -5
  23. package/dist/client/fetch.d.ts.map +1 -1
  24. package/dist/client/fetch.js.map +1 -1
  25. package/dist/client/index.d.ts +12 -1
  26. package/dist/client/index.d.ts.map +1 -1
  27. package/dist/client/index.js +13 -2
  28. package/dist/client/index.js.map +1 -1
  29. package/dist/client/shared.d.ts +6 -1
  30. package/dist/client/shared.d.ts.map +1 -1
  31. package/dist/client/shared.js +29 -3
  32. package/dist/client/shared.js.map +1 -1
  33. package/dist/client/types.d.ts +4 -3
  34. package/dist/client/types.d.ts.map +1 -1
  35. package/dist/client.test.js +3 -7
  36. package/dist/client.test.js.map +1 -1
  37. package/dist/context.d.ts +22 -8
  38. package/dist/context.d.ts.map +1 -1
  39. package/dist/copy-anything.d.ts +6 -0
  40. package/dist/copy-anything.d.ts.map +1 -0
  41. package/dist/copy-anything.js +52 -0
  42. package/dist/copy-anything.js.map +1 -0
  43. package/dist/cors.d.ts +1 -1
  44. package/dist/cors.d.ts.map +1 -1
  45. package/dist/cors.js +16 -7
  46. package/dist/cors.js.map +1 -1
  47. package/dist/cors.test.js +11 -6
  48. package/dist/cors.test.js.map +1 -1
  49. package/dist/deployment-integration.test.d.ts +2 -0
  50. package/dist/deployment-integration.test.d.ts.map +1 -0
  51. package/dist/deployment-integration.test.js +30 -0
  52. package/dist/deployment-integration.test.js.map +1 -0
  53. package/dist/error.d.ts +16 -0
  54. package/dist/error.d.ts.map +1 -1
  55. package/dist/error.js +9 -0
  56. package/dist/error.js.map +1 -1
  57. package/dist/federation/shared/react-dom-client.d.ts +2 -0
  58. package/dist/federation/shared/react-dom-client.d.ts.map +1 -0
  59. package/dist/federation/shared/react-dom-client.js +2 -0
  60. package/dist/federation/shared/react-dom-client.js.map +1 -0
  61. package/dist/federation/shared/react-dom.d.ts +2 -0
  62. package/dist/federation/shared/react-dom.d.ts.map +1 -0
  63. package/dist/federation/shared/react-dom.js +2 -0
  64. package/dist/federation/shared/react-dom.js.map +1 -0
  65. package/dist/federation/shared/react-jsx-runtime.d.ts +2 -0
  66. package/dist/federation/shared/react-jsx-runtime.d.ts.map +1 -0
  67. package/dist/federation/shared/react-jsx-runtime.js +2 -0
  68. package/dist/federation/shared/react-jsx-runtime.js.map +1 -0
  69. package/dist/federation/shared/react.d.ts +2 -0
  70. package/dist/federation/shared/react.d.ts.map +1 -0
  71. package/dist/federation/shared/react.js +2 -0
  72. package/dist/federation/shared/react.js.map +1 -0
  73. package/dist/federation/shared/spiceflow-react.d.ts +2 -0
  74. package/dist/federation/shared/spiceflow-react.d.ts.map +1 -0
  75. package/dist/federation/shared/spiceflow-react.js +2 -0
  76. package/dist/federation/shared/spiceflow-react.js.map +1 -0
  77. package/dist/federation.default.d.ts +5 -0
  78. package/dist/federation.default.d.ts.map +1 -0
  79. package/dist/federation.default.js +11 -0
  80. package/dist/federation.default.js.map +1 -0
  81. package/dist/federation.rsc.d.ts +18 -0
  82. package/dist/federation.rsc.d.ts.map +1 -0
  83. package/dist/federation.rsc.js +225 -0
  84. package/dist/federation.rsc.js.map +1 -0
  85. package/dist/federation.rsc.test.d.ts +2 -0
  86. package/dist/federation.rsc.test.d.ts.map +1 -0
  87. package/dist/federation.rsc.test.js +52 -0
  88. package/dist/federation.rsc.test.js.map +1 -0
  89. package/dist/fetch-client.test.js +79 -6
  90. package/dist/fetch-client.test.js.map +1 -1
  91. package/dist/get-available-port.d.ts +2 -0
  92. package/dist/get-available-port.d.ts.map +1 -0
  93. package/dist/get-available-port.js +49 -0
  94. package/dist/get-available-port.js.map +1 -0
  95. package/dist/index.d.ts +17 -5
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +11 -4
  98. package/dist/index.js.map +1 -1
  99. package/dist/instrumentation.d.ts +40 -0
  100. package/dist/instrumentation.d.ts.map +1 -0
  101. package/dist/instrumentation.js +96 -0
  102. package/dist/instrumentation.js.map +1 -0
  103. package/dist/instrumentation.test.d.ts +2 -0
  104. package/dist/instrumentation.test.d.ts.map +1 -0
  105. package/dist/instrumentation.test.js +453 -0
  106. package/dist/instrumentation.test.js.map +1 -0
  107. package/dist/mcp-client-transport.d.ts.map +1 -1
  108. package/dist/mcp-client-transport.js +9 -4
  109. package/dist/mcp-client-transport.js.map +1 -1
  110. package/dist/mcp-transport.js +1 -1
  111. package/dist/mcp-transport.js.map +1 -1
  112. package/dist/mcp.d.ts +8 -61
  113. package/dist/mcp.d.ts.map +1 -1
  114. package/dist/mcp.js +9 -9
  115. package/dist/mcp.js.map +1 -1
  116. package/dist/mcp.test.d.ts +2 -0
  117. package/dist/mcp.test.d.ts.map +1 -0
  118. package/dist/mcp.test.js +323 -0
  119. package/dist/mcp.test.js.map +1 -0
  120. package/dist/middleware.test.js +203 -6
  121. package/dist/middleware.test.js.map +1 -1
  122. package/dist/openapi-docs-examples.test.d.ts +2 -0
  123. package/dist/openapi-docs-examples.test.d.ts.map +1 -0
  124. package/dist/openapi-docs-examples.test.js +506 -0
  125. package/dist/openapi-docs-examples.test.js.map +1 -0
  126. package/dist/openapi-to-mcp.d.ts.map +1 -1
  127. package/dist/openapi-to-mcp.js +53 -17
  128. package/dist/openapi-to-mcp.js.map +1 -1
  129. package/dist/openapi.d.ts +5 -25
  130. package/dist/openapi.d.ts.map +1 -1
  131. package/dist/openapi.js +14 -10
  132. package/dist/openapi.js.map +1 -1
  133. package/dist/openapi.test.js +22 -7
  134. package/dist/openapi.test.js.map +1 -1
  135. package/dist/prevent-process-exit-if-busy.d.ts.map +1 -1
  136. package/dist/prevent-process-exit-if-busy.js +1 -9
  137. package/dist/prevent-process-exit-if-busy.js.map +1 -1
  138. package/dist/query-coerce.d.ts +10 -0
  139. package/dist/query-coerce.d.ts.map +1 -0
  140. package/dist/query-coerce.js +138 -0
  141. package/dist/query-coerce.js.map +1 -0
  142. package/dist/react/action-abort.d.ts +17 -0
  143. package/dist/react/action-abort.d.ts.map +1 -0
  144. package/dist/react/action-abort.js +31 -0
  145. package/dist/react/action-abort.js.map +1 -0
  146. package/dist/react/components.d.ts +48 -0
  147. package/dist/react/components.d.ts.map +1 -0
  148. package/dist/react/components.js +192 -0
  149. package/dist/react/components.js.map +1 -0
  150. package/dist/react/context.d.ts +9 -0
  151. package/dist/react/context.d.ts.map +1 -0
  152. package/dist/react/context.js +33 -0
  153. package/dist/react/context.js.map +1 -0
  154. package/dist/react/deployment-id.d.ts +2 -0
  155. package/dist/react/deployment-id.d.ts.map +1 -0
  156. package/dist/react/deployment-id.js +7 -0
  157. package/dist/react/deployment-id.js.map +1 -0
  158. package/dist/react/deployment-id.rsc.d.ts +2 -0
  159. package/dist/react/deployment-id.rsc.d.ts.map +1 -0
  160. package/dist/react/deployment-id.rsc.js +22 -0
  161. package/dist/react/deployment-id.rsc.js.map +1 -0
  162. package/dist/react/deployment.d.ts +10 -0
  163. package/dist/react/deployment.d.ts.map +1 -0
  164. package/dist/react/deployment.js +49 -0
  165. package/dist/react/deployment.js.map +1 -0
  166. package/dist/react/deployment.test.d.ts +2 -0
  167. package/dist/react/deployment.test.d.ts.map +1 -0
  168. package/dist/react/deployment.test.js +32 -0
  169. package/dist/react/deployment.test.js.map +1 -0
  170. package/dist/react/document-title.d.ts +4 -0
  171. package/dist/react/document-title.d.ts.map +1 -0
  172. package/dist/react/document-title.js +12 -0
  173. package/dist/react/document-title.js.map +1 -0
  174. package/dist/react/entry.client.d.ts +2 -0
  175. package/dist/react/entry.client.d.ts.map +1 -0
  176. package/dist/react/entry.client.js +376 -0
  177. package/dist/react/entry.client.js.map +1 -0
  178. package/dist/react/entry.rsc.d.ts +5 -0
  179. package/dist/react/entry.rsc.d.ts.map +1 -0
  180. package/dist/react/entry.rsc.js +35 -0
  181. package/dist/react/entry.rsc.js.map +1 -0
  182. package/dist/react/entry.ssr.d.ts +25 -0
  183. package/dist/react/entry.ssr.d.ts.map +1 -0
  184. package/dist/react/entry.ssr.js +258 -0
  185. package/dist/react/entry.ssr.js.map +1 -0
  186. package/dist/react/error-boundary.d.ts +12 -0
  187. package/dist/react/error-boundary.d.ts.map +1 -0
  188. package/dist/react/error-boundary.js +91 -0
  189. package/dist/react/error-boundary.js.map +1 -0
  190. package/dist/react/errors.d.ts +18 -0
  191. package/dist/react/errors.d.ts.map +1 -0
  192. package/dist/react/errors.js +85 -0
  193. package/dist/react/errors.js.map +1 -0
  194. package/dist/react/esm-island.d.ts +5 -0
  195. package/dist/react/esm-island.d.ts.map +1 -0
  196. package/dist/react/esm-island.js +64 -0
  197. package/dist/react/esm-island.js.map +1 -0
  198. package/dist/react/federated-payload.d.ts +47 -0
  199. package/dist/react/federated-payload.d.ts.map +1 -0
  200. package/dist/react/federated-payload.js +372 -0
  201. package/dist/react/federated-payload.js.map +1 -0
  202. package/dist/react/federated-payload.test.d.ts +2 -0
  203. package/dist/react/federated-payload.test.d.ts.map +1 -0
  204. package/dist/react/federated-payload.test.js +103 -0
  205. package/dist/react/federated-payload.test.js.map +1 -0
  206. package/dist/react/fetch.d.ts +6 -0
  207. package/dist/react/fetch.d.ts.map +1 -0
  208. package/dist/react/fetch.js +60 -0
  209. package/dist/react/fetch.js.map +1 -0
  210. package/dist/react/flight-data-context.d.ts +4 -0
  211. package/dist/react/flight-data-context.d.ts.map +1 -0
  212. package/dist/react/flight-data-context.js +3 -0
  213. package/dist/react/flight-data-context.js.map +1 -0
  214. package/dist/react/flight-data-context.rsc.d.ts +4 -0
  215. package/dist/react/flight-data-context.rsc.d.ts.map +1 -0
  216. package/dist/react/flight-data-context.rsc.js +2 -0
  217. package/dist/react/flight-data-context.rsc.js.map +1 -0
  218. package/dist/react/format-server-error.d.ts +2 -0
  219. package/dist/react/format-server-error.d.ts.map +1 -0
  220. package/dist/react/format-server-error.js +51 -0
  221. package/dist/react/format-server-error.js.map +1 -0
  222. package/dist/react/format-server-error.test.d.ts +2 -0
  223. package/dist/react/format-server-error.test.d.ts.map +1 -0
  224. package/dist/react/format-server-error.test.js +65 -0
  225. package/dist/react/format-server-error.test.js.map +1 -0
  226. package/dist/react/handle-ssr.d.ts +2 -0
  227. package/dist/react/handle-ssr.d.ts.map +1 -0
  228. package/dist/react/handle-ssr.js +6 -0
  229. package/dist/react/handle-ssr.js.map +1 -0
  230. package/dist/react/handle-ssr.rsc.d.ts +2 -0
  231. package/dist/react/handle-ssr.rsc.d.ts.map +1 -0
  232. package/dist/react/handle-ssr.rsc.js +14 -0
  233. package/dist/react/handle-ssr.rsc.js.map +1 -0
  234. package/dist/react/head-processing.d.ts +6 -0
  235. package/dist/react/head-processing.d.ts.map +1 -0
  236. package/dist/react/head-processing.js +99 -0
  237. package/dist/react/head-processing.js.map +1 -0
  238. package/dist/react/head-tags.d.ts +31 -0
  239. package/dist/react/head-tags.d.ts.map +1 -0
  240. package/dist/react/head-tags.js +2 -0
  241. package/dist/react/head-tags.js.map +1 -0
  242. package/dist/react/head.d.ts +29 -0
  243. package/dist/react/head.d.ts.map +1 -0
  244. package/dist/react/head.js +99 -0
  245. package/dist/react/head.js.map +1 -0
  246. package/dist/react/head.test.d.ts +2 -0
  247. package/dist/react/head.test.d.ts.map +1 -0
  248. package/dist/react/head.test.js +18 -0
  249. package/dist/react/head.test.js.map +1 -0
  250. package/dist/react/index.d.ts +14 -0
  251. package/dist/react/index.d.ts.map +1 -0
  252. package/dist/react/index.js +12 -0
  253. package/dist/react/index.js.map +1 -0
  254. package/dist/react/index.rsc.d.ts +13 -0
  255. package/dist/react/index.rsc.d.ts.map +1 -0
  256. package/dist/react/index.rsc.js +10 -0
  257. package/dist/react/index.rsc.js.map +1 -0
  258. package/dist/react/link.d.ts +5 -0
  259. package/dist/react/link.d.ts.map +1 -0
  260. package/dist/react/link.js +45 -0
  261. package/dist/react/link.js.map +1 -0
  262. package/dist/react/loader-utils.d.ts +2 -0
  263. package/dist/react/loader-utils.d.ts.map +1 -0
  264. package/dist/react/loader-utils.js +34 -0
  265. package/dist/react/loader-utils.js.map +1 -0
  266. package/dist/react/prerender.d.ts +15 -0
  267. package/dist/react/prerender.d.ts.map +1 -0
  268. package/dist/react/prerender.js +149 -0
  269. package/dist/react/prerender.js.map +1 -0
  270. package/dist/react/progress.d.ts +35 -0
  271. package/dist/react/progress.d.ts.map +1 -0
  272. package/dist/react/progress.js +191 -0
  273. package/dist/react/progress.js.map +1 -0
  274. package/dist/react/progress.test.d.ts +2 -0
  275. package/dist/react/progress.test.d.ts.map +1 -0
  276. package/dist/react/progress.test.js +92 -0
  277. package/dist/react/progress.test.js.map +1 -0
  278. package/dist/react/react-cache.test.d.ts +2 -0
  279. package/dist/react/react-cache.test.d.ts.map +1 -0
  280. package/dist/react/react-cache.test.js +40 -0
  281. package/dist/react/react-cache.test.js.map +1 -0
  282. package/dist/react/remote-island.d.ts +6 -0
  283. package/dist/react/remote-island.d.ts.map +1 -0
  284. package/dist/react/remote-island.js +216 -0
  285. package/dist/react/remote-island.js.map +1 -0
  286. package/dist/react/router-events.test.d.ts +2 -0
  287. package/dist/react/router-events.test.d.ts.map +1 -0
  288. package/dist/react/router-events.test.js +180 -0
  289. package/dist/react/router-events.test.js.map +1 -0
  290. package/dist/react/router-ssr.test.d.ts +2 -0
  291. package/dist/react/router-ssr.test.d.ts.map +1 -0
  292. package/dist/react/router-ssr.test.js +88 -0
  293. package/dist/react/router-ssr.test.js.map +1 -0
  294. package/dist/react/router.d.ts +92 -0
  295. package/dist/react/router.d.ts.map +1 -0
  296. package/dist/react/router.js +362 -0
  297. package/dist/react/router.js.map +1 -0
  298. package/dist/react/router.rsc.d.ts +15 -0
  299. package/dist/react/router.rsc.d.ts.map +1 -0
  300. package/dist/react/router.rsc.js +85 -0
  301. package/dist/react/router.rsc.js.map +1 -0
  302. package/dist/react/sanitize-error.d.ts +2 -0
  303. package/dist/react/sanitize-error.d.ts.map +1 -0
  304. package/dist/react/sanitize-error.js +72 -0
  305. package/dist/react/sanitize-error.js.map +1 -0
  306. package/dist/react/sanitize-error.test.d.ts +2 -0
  307. package/dist/react/sanitize-error.test.d.ts.map +1 -0
  308. package/dist/react/sanitize-error.test.js +45 -0
  309. package/dist/react/sanitize-error.test.js.map +1 -0
  310. package/dist/react/scroll-restoration.d.ts +11 -0
  311. package/dist/react/scroll-restoration.d.ts.map +1 -0
  312. package/dist/react/scroll-restoration.js +136 -0
  313. package/dist/react/scroll-restoration.js.map +1 -0
  314. package/dist/react/spiceflow-dirs.d.ts +3 -0
  315. package/dist/react/spiceflow-dirs.d.ts.map +1 -0
  316. package/dist/react/spiceflow-dirs.js +6 -0
  317. package/dist/react/spiceflow-dirs.js.map +1 -0
  318. package/dist/react/spiceflow-dirs.rsc.d.ts +2 -0
  319. package/dist/react/spiceflow-dirs.rsc.d.ts.map +1 -0
  320. package/dist/react/spiceflow-dirs.rsc.js +5 -0
  321. package/dist/react/spiceflow-dirs.rsc.js.map +1 -0
  322. package/dist/react/ssr-error-fallback.test.d.ts +2 -0
  323. package/dist/react/ssr-error-fallback.test.d.ts.map +1 -0
  324. package/dist/react/ssr-error-fallback.test.js +75 -0
  325. package/dist/react/ssr-error-fallback.test.js.map +1 -0
  326. package/dist/react/transform.d.ts +5 -0
  327. package/dist/react/transform.d.ts.map +1 -0
  328. package/dist/react/transform.js +137 -0
  329. package/dist/react/transform.js.map +1 -0
  330. package/dist/react/transform.test.d.ts +2 -0
  331. package/dist/react/transform.test.d.ts.map +1 -0
  332. package/dist/react/transform.test.js +57 -0
  333. package/dist/react/transform.test.js.map +1 -0
  334. package/dist/render-react.test.d.ts +2 -0
  335. package/dist/render-react.test.d.ts.map +1 -0
  336. package/dist/render-react.test.js +388 -0
  337. package/dist/render-react.test.js.map +1 -0
  338. package/dist/router-context.d.ts +16 -0
  339. package/dist/router-context.d.ts.map +1 -0
  340. package/dist/router-context.default.d.ts +8 -0
  341. package/dist/router-context.default.d.ts.map +1 -0
  342. package/dist/router-context.default.js +26 -0
  343. package/dist/router-context.default.js.map +1 -0
  344. package/dist/router-context.js +20 -0
  345. package/dist/router-context.js.map +1 -0
  346. package/dist/rsc-runtime.d.ts +2 -0
  347. package/dist/rsc-runtime.d.ts.map +1 -0
  348. package/dist/rsc-runtime.default.d.ts +7 -0
  349. package/dist/rsc-runtime.default.d.ts.map +1 -0
  350. package/dist/rsc-runtime.default.js +15 -0
  351. package/dist/rsc-runtime.default.js.map +1 -0
  352. package/dist/rsc-runtime.js +4 -0
  353. package/dist/rsc-runtime.js.map +1 -0
  354. package/dist/server-file-guard.d.ts +3 -0
  355. package/dist/server-file-guard.d.ts.map +1 -0
  356. package/dist/server-file-guard.js +54 -0
  357. package/dist/server-file-guard.js.map +1 -0
  358. package/dist/simple.benchmark.js +1 -1
  359. package/dist/smoke.test.d.ts +2 -0
  360. package/dist/smoke.test.d.ts.map +1 -0
  361. package/dist/smoke.test.js +135 -0
  362. package/dist/smoke.test.js.map +1 -0
  363. package/dist/spiceflow.d.ts +114 -38
  364. package/dist/spiceflow.d.ts.map +1 -1
  365. package/dist/spiceflow.js +1419 -251
  366. package/dist/spiceflow.js.map +1 -1
  367. package/dist/spiceflow.test.js +1694 -242
  368. package/dist/spiceflow.test.js.map +1 -1
  369. package/dist/standard-schema.d.ts +32 -0
  370. package/dist/standard-schema.d.ts.map +1 -0
  371. package/dist/standard-schema.js +4 -0
  372. package/dist/standard-schema.js.map +1 -0
  373. package/dist/static-node.d.ts +2 -2
  374. package/dist/static-node.d.ts.map +1 -1
  375. package/dist/static-node.js +62 -17
  376. package/dist/static-node.js.map +1 -1
  377. package/dist/static-node.test.d.ts +2 -0
  378. package/dist/static-node.test.d.ts.map +1 -0
  379. package/dist/static-node.test.js +194 -0
  380. package/dist/static-node.test.js.map +1 -0
  381. package/dist/static.benchmark.js +2 -2
  382. package/dist/static.d.ts +10 -1
  383. package/dist/static.d.ts.map +1 -1
  384. package/dist/static.js +70 -51
  385. package/dist/static.js.map +1 -1
  386. package/dist/stream.test.js +31 -33
  387. package/dist/stream.test.js.map +1 -1
  388. package/dist/trace-dependencies.d.ts +9 -0
  389. package/dist/trace-dependencies.d.ts.map +1 -0
  390. package/dist/trace-dependencies.js +38 -0
  391. package/dist/trace-dependencies.js.map +1 -0
  392. package/dist/trie-router/node.d.ts +8 -0
  393. package/dist/trie-router/node.d.ts.map +1 -0
  394. package/dist/trie-router/node.js +172 -0
  395. package/dist/trie-router/node.js.map +1 -0
  396. package/dist/trie-router/node.test.d.ts +2 -0
  397. package/dist/trie-router/node.test.d.ts.map +1 -0
  398. package/dist/trie-router/node.test.js +859 -0
  399. package/dist/trie-router/node.test.js.map +1 -0
  400. package/dist/trie-router/router.d.ts +9 -0
  401. package/dist/trie-router/router.d.ts.map +1 -0
  402. package/dist/trie-router/router.js +23 -0
  403. package/dist/trie-router/router.js.map +1 -0
  404. package/dist/trie-router/url.d.ts +19 -0
  405. package/dist/trie-router/url.d.ts.map +1 -0
  406. package/dist/trie-router/url.js +235 -0
  407. package/dist/trie-router/url.js.map +1 -0
  408. package/dist/trie-router/utils.d.ts +34 -0
  409. package/dist/trie-router/utils.d.ts.map +1 -0
  410. package/dist/trie-router/utils.js +36 -0
  411. package/dist/trie-router/utils.js.map +1 -0
  412. package/dist/types.d.ts +67 -27
  413. package/dist/types.d.ts.map +1 -1
  414. package/dist/types.js +1 -2
  415. package/dist/types.js.map +1 -1
  416. package/dist/types.test.js +6 -8
  417. package/dist/types.test.js.map +1 -1
  418. package/dist/utils.d.ts +4 -7
  419. package/dist/utils.d.ts.map +1 -1
  420. package/dist/utils.js +5 -6
  421. package/dist/utils.js.map +1 -1
  422. package/dist/vercel.d.ts +3 -0
  423. package/dist/vercel.d.ts.map +1 -0
  424. package/dist/vercel.js +134 -0
  425. package/dist/vercel.js.map +1 -0
  426. package/dist/vite-outdir.test.d.ts +2 -0
  427. package/dist/vite-outdir.test.d.ts.map +1 -0
  428. package/dist/vite-outdir.test.js +83 -0
  429. package/dist/vite-outdir.test.js.map +1 -0
  430. package/dist/vite.d.ts +13 -0
  431. package/dist/vite.d.ts.map +1 -0
  432. package/dist/vite.js +868 -0
  433. package/dist/vite.js.map +1 -0
  434. package/dist/waitUntil.test.js +12 -12
  435. package/dist/waitUntil.test.js.map +1 -1
  436. package/dist/zod.test.js +2 -2
  437. package/package.json +94 -21
  438. package/src/_node-server.test.ts +72 -0
  439. package/src/_node-server.ts +144 -72
  440. package/src/action-context.ts +36 -0
  441. package/src/any-spiceflow-types.test.ts +253 -0
  442. package/src/base-path.ts +22 -0
  443. package/src/client/fetch.ts +98 -46
  444. package/src/client/index.ts +16 -3
  445. package/src/client/shared.ts +46 -6
  446. package/src/client/types.ts +6 -3
  447. package/src/client.test.ts +10 -13
  448. package/src/context.ts +29 -11
  449. package/src/copy-anything.ts +75 -0
  450. package/src/cors.test.ts +14 -5
  451. package/src/cors.ts +17 -8
  452. package/src/deployment-integration.test.ts +39 -0
  453. package/src/error.ts +25 -0
  454. package/src/federation/shared/react-dom-client.ts +1 -0
  455. package/src/federation/shared/react-dom.ts +5 -0
  456. package/src/federation/shared/react-jsx-runtime.ts +1 -0
  457. package/src/federation/shared/react.ts +10 -0
  458. package/src/federation/shared/spiceflow-react.ts +14 -0
  459. package/src/federation.default.ts +14 -0
  460. package/src/federation.rsc.test.ts +65 -0
  461. package/src/federation.rsc.ts +287 -0
  462. package/src/fetch-client.test.ts +89 -12
  463. package/src/get-available-port.ts +54 -0
  464. package/src/globals.d.ts +12 -0
  465. package/src/index.ts +25 -5
  466. package/src/instrumentation.test.ts +555 -0
  467. package/src/instrumentation.ts +141 -0
  468. package/src/mcp-client-transport.ts +11 -9
  469. package/src/mcp-transport.ts +1 -1
  470. package/src/mcp.test.ts +407 -0
  471. package/src/mcp.ts +9 -9
  472. package/src/middleware.test.ts +247 -13
  473. package/src/openapi-docs-examples.test.ts +598 -0
  474. package/src/openapi-to-mcp.ts +78 -35
  475. package/src/openapi.test.ts +25 -7
  476. package/src/openapi.ts +18 -11
  477. package/src/prevent-process-exit-if-busy.ts +2 -22
  478. package/src/query-coerce.ts +154 -0
  479. package/src/react/action-abort.ts +34 -0
  480. package/src/react/ambient.d.ts +40 -0
  481. package/src/react/components.tsx +335 -0
  482. package/src/react/context.tsx +52 -0
  483. package/src/react/deployment-id.rsc.ts +22 -0
  484. package/src/react/deployment-id.ts +7 -0
  485. package/src/react/deployment.test.ts +40 -0
  486. package/src/react/deployment.ts +61 -0
  487. package/src/react/document-title.tsx +13 -0
  488. package/src/react/entry.client.tsx +443 -0
  489. package/src/react/entry.rsc.tsx +37 -0
  490. package/src/react/entry.ssr.tsx +351 -0
  491. package/src/react/error-boundary.tsx +137 -0
  492. package/src/react/errors.tsx +103 -0
  493. package/src/react/esm-island.tsx +87 -0
  494. package/src/react/federated-payload.test.ts +148 -0
  495. package/src/react/federated-payload.ts +503 -0
  496. package/src/react/fetch.ts +76 -0
  497. package/src/react/flight-data-context.rsc.ts +6 -0
  498. package/src/react/flight-data-context.tsx +6 -0
  499. package/src/react/format-server-error.test.ts +88 -0
  500. package/src/react/format-server-error.ts +65 -0
  501. package/src/react/handle-ssr.rsc.ts +19 -0
  502. package/src/react/handle-ssr.ts +7 -0
  503. package/src/react/head-processing.tsx +158 -0
  504. package/src/react/head-tags.tsx +284 -0
  505. package/src/react/head.test.tsx +53 -0
  506. package/src/react/head.tsx +140 -0
  507. package/src/react/index.rsc.ts +22 -0
  508. package/src/react/index.ts +23 -0
  509. package/src/react/link.tsx +58 -0
  510. package/src/react/loader-utils.ts +33 -0
  511. package/src/react/prerender.ts +203 -0
  512. package/src/react/progress.test.ts +108 -0
  513. package/src/react/progress.tsx +246 -0
  514. package/src/react/react-cache.test.ts +47 -0
  515. package/src/react/remote-island.tsx +281 -0
  516. package/src/react/router-events.test.ts +216 -0
  517. package/src/react/router-ssr.test.tsx +121 -0
  518. package/src/react/router.rsc.ts +106 -0
  519. package/src/react/router.tsx +573 -0
  520. package/src/react/sanitize-error.test.ts +113 -0
  521. package/src/react/sanitize-error.ts +80 -0
  522. package/src/react/scroll-restoration.tsx +184 -0
  523. package/src/react/spiceflow-dirs.rsc.ts +5 -0
  524. package/src/react/spiceflow-dirs.ts +6 -0
  525. package/src/react/ssr-error-fallback.test.tsx +116 -0
  526. package/src/react/transform.test.ts +65 -0
  527. package/src/react/transform.ts +182 -0
  528. package/src/render-react.test.ts +419 -0
  529. package/src/router-context.default.ts +29 -0
  530. package/src/router-context.ts +35 -0
  531. package/src/rsc-runtime.default.ts +18 -0
  532. package/src/rsc-runtime.ts +10 -0
  533. package/src/server-file-guard.ts +60 -0
  534. package/src/simple.benchmark.ts +1 -1
  535. package/src/smoke.test.ts +160 -0
  536. package/src/spiceflow.test.ts +3286 -1394
  537. package/src/spiceflow.tsx +3433 -0
  538. package/src/standard-schema.ts +39 -0
  539. package/src/static-node.test.ts +246 -0
  540. package/src/static-node.ts +84 -19
  541. package/src/static.benchmark.ts +2 -2
  542. package/src/static.ts +97 -58
  543. package/src/stream.test.ts +49 -49
  544. package/src/trace-dependencies.ts +57 -0
  545. package/src/trie-router/node.test.ts +937 -0
  546. package/src/trie-router/node.ts +246 -0
  547. package/src/trie-router/router.ts +27 -0
  548. package/src/trie-router/url.ts +305 -0
  549. package/src/trie-router/utils.ts +69 -0
  550. package/src/types.test.ts +7 -9
  551. package/src/types.ts +249 -52
  552. package/src/utils.ts +10 -12
  553. package/src/vercel.ts +213 -0
  554. package/src/vite-outdir.test.ts +135 -0
  555. package/src/vite.tsx +1041 -0
  556. package/src/waitUntil.test.ts +25 -21
  557. package/src/zod.test.ts +2 -2
  558. package/src/spiceflow.ts +0 -1648
package/README.md CHANGED
@@ -5,237 +5,222 @@
5
5
  <br/>
6
6
  <br/>
7
7
  <h1>spiceflow</h1>
8
- <p>fast, simple and type safe API framework</p>
8
+ <p>type safe API and React Server Components framework for Node, Bun, and Cloudflare</p>
9
9
  <br/>
10
10
  <br/>
11
11
  </div>
12
12
 
13
- Spiceflow is a lightweight, type-safe API framework for building web services using modern web standards. Read the source code on [GitHub](https://github.com/remorses/spiceflow).
13
+ Spiceflow is a type-safe API framework and full-stack React RSC framework focused on absolute simplicity. It works across all JavaScript runtimes: Node.js, Bun, and Cloudflare Workers. Read the source code on [GitHub](https://github.com/remorses/spiceflow).
14
14
 
15
15
  ## Features
16
16
 
17
+ - Full-stack React framework with React Server Components (RSC), server actions, layouts, and automatic client code splitting
18
+ - Works everywhere: Node.js, Bun, and Cloudflare Workers with the same code
17
19
  - Type safe schema based validation via Zod
20
+ - Type safe fetch client with full inference on path params, query, body, and response
21
+ - Simple and intuitive API using web standard Request and Response
18
22
  - Can easily generate OpenAPI spec based on your routes
19
- - Native support for [Fern](https://github.com/fern-api/fern) to generate docs and SDKs (see example docs [here](https://remorses.docs.buildwithfern.com))
20
23
  - Support for [Model Context Protocol](https://modelcontextprotocol.io/) to easily wire your app with LLMs
21
- - Type safe RPC client generation
22
- - Simple and intuitive API
23
- - Uses web standards for requests and responses
24
24
  - Supports async generators for streaming via server sent events
25
25
  - Modular design with `.use()` for mounting sub-apps
26
- - Base path support
26
+ - Built-in [OpenTelemetry](https://opentelemetry.io/) tracing with zero overhead when disabled
27
27
 
28
28
  ## Installation
29
29
 
30
30
  ```bash
31
- npm install spiceflow zod
31
+ npm install spiceflow@rsc
32
32
  ```
33
33
 
34
- ## Basic Usage
34
+ ## AI Agents
35
35
 
36
- Objects returned from route handlers are automatically serialized to JSON
36
+ To let your AI coding agent know how to use spiceflow, run:
37
37
 
38
- > Notice that Spiceflow also has legacy methods for `.port`, `.get` etc. that use a different API with positional arguments. Using `.route` is preferred
38
+ ```bash
39
+ npx -y skills add remorses/spiceflow
40
+ ```
39
41
 
40
- ```ts
42
+ ## Basic Usage
43
+
44
+ API routes return JSON automatically. React pages use `.page()` and `.layout()` for server-rendered UI with client interactivity:
45
+
46
+ ```tsx
41
47
  import { Spiceflow } from 'spiceflow'
48
+ import { Counter } from './counter'
42
49
 
43
- const app = new Spiceflow()
44
- .route({
45
- method: 'POST',
46
- path: '/hello',
47
- handler() {
48
- return 'Hello, World!'
49
- },
50
+ export const app = new Spiceflow()
51
+ .get('/api/hello', () => {
52
+ return { message: 'Hello, World!' }
50
53
  })
51
- .route({
52
- method: 'POST',
53
- path: '/echo',
54
- async handler({ request }) {
55
- const body = await request.json()
56
- return { echo: body }
57
- },
54
+ .layout('/*', async ({ children }) => {
55
+ return (
56
+ <html>
57
+ <body>{children}</body>
58
+ </html>
59
+ )
60
+ })
61
+ .page('/', async () => {
62
+ return (
63
+ <div>
64
+ <h1>Home</h1>
65
+ <Counter />
66
+ </div>
67
+ )
68
+ })
69
+ .page('/about', async () => {
70
+ return <h1>About</h1>
58
71
  })
59
72
 
60
73
  app.listen(3000)
61
74
  ```
62
75
 
63
- > Never declare app and add routes separately, that way you lose the type safety. Instead always append routes with .route in a single expression.
76
+ <details>
77
+ <summary>When to use .route() vs .get()/.post()</summary>
64
78
 
65
- ```ts
66
- // This is an example of what NOT to do when using Spiceflow
79
+ Use `.route()` instead of `.get()`/`.post()` when you want to pass Zod schemas for validation — it accepts `request`, `response`, `query`, and `params` schemas.
80
+
81
+ </details>
82
+
83
+ ## Two Ways to Use Spiceflow
67
84
 
85
+ Spiceflow works as a **standalone API framework** or as a **full-stack React framework** — same router, same type safety, same code.
86
+
87
+ **API only** — no Vite, no React. Just install `spiceflow` and build type-safe APIs with Zod validation, streaming, OpenAPI, and a type-safe fetch client:
88
+
89
+ ```ts
68
90
  import { Spiceflow } from 'spiceflow'
69
91
 
70
- // DO NOT declare the app separately and add routes later
71
92
  const app = new Spiceflow()
93
+ .get('/hello', () => ({ message: 'Hello!' }))
72
94
 
73
- // Do NOT do this! Defining routes separately will lose type safety
74
- app.route({
75
- method: 'GET',
76
- path: '/hello',
77
- handler() {
78
- return 'Hello, World!'
79
- },
80
- })
81
- // Do NOT do this! Adding routes separately like this will lose type safety
82
- app.route({
83
- method: 'POST',
84
- path: '/echo',
85
- async handler({ request }) {
86
- const body = await request.json()
87
- return body
88
- },
95
+ app.listen(3000)
96
+ ```
97
+
98
+ **Full-stack React (RSC)** — add the Vite plugin to get server components, client components, layouts, server actions, and automatic code splitting. All API features still work alongside React pages:
99
+
100
+ ```ts
101
+ // vite.config.ts
102
+ import react from '@vitejs/plugin-react'
103
+ import { defineConfig } from 'vite'
104
+ import spiceflow from 'spiceflow/vite'
105
+
106
+ export default defineConfig({
107
+ plugins: [react(), spiceflow({ entry: './src/main.tsx' })],
89
108
  })
90
109
  ```
91
110
 
92
- ## Returning JSON
111
+ ## `use client` trap in optimized `node_modules` dependencies
93
112
 
94
- Spiceflow automatically serializes objects returned from handlers to JSON, so you don't need to wrap them in a `Response` object:
113
+ **This section is about published dependencies from `node_modules`, not your app's own `src/` files.**
95
114
 
96
- ```ts
97
- import { Spiceflow } from 'spiceflow'
115
+ Your own app code is usually treated as source by Vite, so its module boundaries are normally preserved. This problem shows up when a package from `node_modules` contains both server code and client code, and Vite prebundles that dependency into an optimized server chunk.
98
116
 
99
- const app = new Spiceflow()
100
- .route({
101
- method: 'GET',
102
- path: '/user',
103
- handler() {
104
- // Return object directly - no need for new Response()
105
- return { id: 1, name: 'John', email: 'john@example.com' }
106
- },
107
- })
108
- .route({
109
- method: 'POST',
110
- path: '/data',
111
- async handler({ request }) {
112
- const body = await request.json()
113
- // Objects are automatically serialized to JSON
114
- return {
115
- received: body,
116
- timestamp: new Date().toISOString(),
117
- processed: true,
118
- }
119
- },
120
- })
117
+ If a bug only reproduces when importing a library from `node_modules`, and not when writing similar code directly in your app, this is the failure mode to look for.
118
+
119
+ When that happens, the `use client` boundary only works if the client file stays a separate module boundary.
120
+
121
+ **Bad pattern**
122
+
123
+ - published dependency has a server-safe entry file that imports a client file with a relative import
124
+ - Vite dependency optimization flattens both files into one optimized server dependency
125
+ - the client module gets evaluated against `react-server`
126
+ - startup crashes before the app renders
127
+
128
+ **Typical symptoms**
129
+
130
+ - `Class extends value undefined is not a constructor or null`
131
+ - `Component` / `useState` / `useEffect` / `prefetchDNS` is `undefined`
132
+ - Cloudflare dev crashes during worker startup before any request hits your app
133
+
134
+ ```text
135
+ published dependency entry
136
+ └─ imports ./client-widget.tsx ('use client')
137
+ └─ optimizer flattens package into one server chunk in node_modules/.vite
138
+ └─ client code now runs with react.react-server
139
+ └─ boom
121
140
  ```
122
141
 
123
- ## Type Safety for RPC
142
+ **Safer pattern**
124
143
 
125
- To maintain type safety when using the RPC client, it's recommended to **throw Response objects for errors** and **return objects directly for success cases**. This pattern ensures that the returned value types are properly inferred:
144
+ - keep the main package entry server-safe
145
+ - expose client code through a package subpath such as `my-lib/client`
146
+ - import the client boundary through that package subpath instead of a relative path from the server entry
126
147
 
127
148
  ```ts
128
- import { Spiceflow } from 'spiceflow'
129
- import { z } from 'zod'
130
-
131
- const app = new Spiceflow()
132
- .route({
133
- method: 'GET',
134
- path: '/users/:id',
135
- params: z.object({
136
- id: z.string(),
137
- }),w
138
- response: z.object({
139
- id: z.string(),
140
- name: z.string(),
141
- email: z.string(),
142
- }),
143
- handler({ params }) {
144
- const user = getUserById(params.id)
149
+ // safer than importing ./client-widget directly from the main entry
150
+ import { ClientWidget } from 'my-lib/client'
151
+ ```
145
152
 
146
- if (!user) {
147
- // Throw Response for errors to maintain type safety
148
- throw new Response('User not found', { status: 404 })
149
- }
153
+ This matters most in Vite RSC dev, Cloudflare runner startup, and any environment that eagerly imports the full worker/module graph to inspect exports.
150
154
 
151
- // Return object directly for success - type will be properly inferred
152
- return {
153
- id: user.id,
154
- name: user.name,
155
- email: user.email,
156
- }
157
- },
158
- })
159
- .route({
160
- method: 'POST',
161
- path: '/users',
162
- request: z.object({
163
- name: z.string(),
164
- email: z.string().email(),
165
- }),
166
- response: z.object({
167
- id: z.string(),
168
- name: z.string(),
169
- email: z.string(),
170
- }),
171
- async handler({ request }) {
172
- const body = await request.json()
155
+ If this only happens for a package from `node_modules` and not for your app's own `src/` files, this is the exact class of issue described here.
173
156
 
174
- if (await userExists(body.email)) {
175
- // Throw Response for errors
176
- throw new Response('User already exists', { status: 409 })
177
- }
157
+ ### How to debug this
178
158
 
179
- const newUser = await createUser(body)
159
+ 1. **Look at the optimized dep output**
160
+ - inspect `node_modules/.vite/deps_rsc/` and `deps_ssr/`
161
+ - search for the crashing package and check whether client-only code got bundled into a server chunk
180
162
 
181
- // Return object directly - RPC client will have proper typing
182
- return {
183
- id: newUser.id,
184
- name: newUser.name,
185
- email: newUser.email,
186
- }
187
- },
188
- })
163
+ 2. **Search for client-only React APIs in server chunks**
164
+ - things like `extends ...Component`, `useState`, `useEffect`, `prefetchDNS`, `preconnect`, `Suspense`
165
+ - if they are imported from a `react-server` build, your boundary was lost
189
166
 
190
- // RPC client usage with proper type inference
191
- import { createSpiceflowClient } from 'spiceflow/client'
167
+ 3. **Check whether the crash happens at import time**
168
+ - if dev dies before any request, the worker entry or export-inspection path is evaluating the bad module eagerly
192
169
 
193
- const client = createSpiceflowClient<typeof app>('http://localhost:3000')
170
+ 4. **Inspect package boundaries**
171
+ - main entry should not statically pull in a client file via `./relative-import`
172
+ - move the client module behind an exported subpath like `pkg/client`
194
173
 
195
- async function example() {
196
- // TypeScript knows data is { id: string, name: string, email: string } | undefined
197
- const { data, error } = await client.users({ id: '123' }).get()
174
+ 5. **Validate the fix**
175
+ - rebuild the package
176
+ - restart dev so Vite re-optimizes deps
177
+ - confirm the server starts and the bad optimized chunk disappears or no longer contains the client code
198
178
 
199
- if (error) {
200
- console.error('Error:', error) // Error handling
201
- return
202
- }
179
+ Useful search pattern:
203
180
 
204
- // data is properly typed here
205
- console.log('User:', data.name, data.email)
206
- }
181
+ ```bash
182
+ rg -n "extends .*Component|useState|useEffect|prefetchDNS|preconnect|react-server" node_modules/.vite
207
183
  ```
208
184
 
209
- With this pattern:
185
+ ## Returning JSON
210
186
 
211
- - **Success responses**: Return objects directly for automatic JSON serialization and proper type inference
212
- - **Error responses**: Throw `Response` objects to maintain the error/success distinction in the RPC client
213
- - **Type safety**: The RPC client will correctly infer the return type as the success object type
187
+ Spiceflow automatically serializes objects returned from handlers to JSON. Return plain objects directly this is the preferred approach because the typed fetch client can infer the response type automatically:
214
188
 
215
- ## Comparisons
189
+ ```ts
190
+ import { Spiceflow } from 'spiceflow'
216
191
 
217
- #### Elysia
192
+ export const app = new Spiceflow()
193
+ .get('/user', () => {
194
+ // Preferred — return type is inferred by the typed fetch client
195
+ return { id: 1, name: 'John', email: 'john@example.com' }
196
+ })
197
+ .post('/data', async ({ request }) => {
198
+ const body = await request.json()
199
+ return {
200
+ received: body,
201
+ timestamp: new Date().toISOString(),
202
+ processed: true,
203
+ }
204
+ })
205
+ ```
218
206
 
219
- This project was born as a fork of Elysia with several changes:
207
+ When you need to return a non-200 status code, use the `json()` helper instead of `Response.json()`. It works the same way at runtime but preserves the data type and status code in the type system — so the fetch client gets full type safety for each status code:
220
208
 
221
- - Use Zod instead of Typebox
222
- - Do not compile user code with `aot` and `eval`, Elysia is very difficult to contribue to because the app is generated by compiling the user routes with `new Function()`, which also causes [several bugs](https://github.com/elysiajs/elysia/pull/773)
223
- - Better async generator support by using SSE
209
+ ```ts
210
+ import { Spiceflow, json } from 'spiceflow'
224
211
 
225
- #### Hono
212
+ // Preferred — type-safe, fetch client knows this is a 404 with { error: string }
213
+ throw json({ error: 'Not found' }, { status: 404 })
226
214
 
227
- This project shares many inspirations with Hono with many differences
215
+ // Avoid Response.json() erases the type, fetch client sees unknown
216
+ throw Response.json({ error: 'Not found' }, { status: 404 })
217
+ ```
228
218
 
229
- - First class OpenAPI support, you don't need to change anything to produce an OpenAPI spec, just add the `openapi` plugin to automaitcally export your openapi schema on `/openapi`
230
- - Much simpler framework, everything is done with native `Request` and `Response` objects instead of framework specific utilities
231
- - Support for async generators
232
- - Adding schemas to your routes is easier and does not require using `validator` functions, which slow down TypeScript inference
233
- - The generated RPC client has much faster type inference, intellisense in VSCode appears in milliseconds instead of seconds
234
- - Spiceflow uses whatwg Request and Response instead of custom utilities like `c.text` and `c.req`
219
+ ## Routes & Validation
235
220
 
236
- ## Requests and Responses
221
+ Define routes with Zod schemas for automatic request and response validation. Use `.route()` with `request`, `response`, `query`, and `params` schemas for full type safety.
237
222
 
238
- ### POST Request with Body Schema
223
+ ### Request Validation
239
224
 
240
225
  ```ts
241
226
  import { z } from 'zod'
@@ -255,8 +240,18 @@ new Spiceflow().route({
255
240
  })
256
241
  ```
257
242
 
258
- > Notice that to get the body of the request, you need to call `request.json()` to parse the body as JSON.
259
- > Spiceflow does not parse the Body automatically, there is no body field in the Spiceflow route argument, instead you call either `request.json()` or `request.formData()` to get the body and validate it at the same time. This works by wrapping the request in a `SpiceflowRequest` instance, which has a `json()` and `formData()` method that parse the body and validate it. The returned data will have the correct schema type instead of `any`.
243
+ <details>
244
+ <summary>How body parsing works</summary>
245
+
246
+ To get the body of the request, call `request.json()` to parse the body as JSON. Spiceflow does not parse the body automatically — there is no `body` field in the route argument. Instead you call either `request.json()` or `request.formData()` to get the body and validate it at the same time. The returned data will have the correct schema type instead of `any`.
247
+
248
+ The `request` object in every handler and middleware is a `SpiceflowRequest`, which extends the standard Web `Request`. On top of the standard API, it adds:
249
+
250
+ - **`request.parsedUrl`** — a lazily cached `URL` object, so you don't need to write `new URL(request.url)` yourself. Accessing `.pathname`, `.searchParams`, etc. is one property access away
251
+ - **`request.json()` / `request.formData()`** — parse and validate the body against the route schema in one step, returning typed data instead of `any`
252
+ - **`request.originalUrl`** — the raw transport URL before Spiceflow normalizes `.rsc` pathnames
253
+
254
+ </details>
260
255
 
261
256
  ### Response Schema
262
257
 
@@ -274,9 +269,6 @@ new Spiceflow().route({
274
269
  id: z.number(),
275
270
  name: z.string(),
276
271
  }),
277
- params: z.object({
278
- id: z.string(),
279
- }),
280
272
  async handler({ request, params }) {
281
273
  const typedJson = await request.json() // this body will have the correct type
282
274
  return { id: Number(params.id), name: typedJson.name }
@@ -284,219 +276,229 @@ new Spiceflow().route({
284
276
  })
285
277
  ```
286
278
 
287
- ## Generate RPC Client
279
+ ### Typed Error Responses
280
+
281
+ When a route declares a status-code response map, use the `json()` helper from `spiceflow` to return or throw non-200 responses with full type safety. Unlike `Response.json()`, `json()` carries the data type and status code through the type system — so TypeScript validates that the status code exists in the response schema and the body matches the declared shape.
288
282
 
289
283
  ```ts
290
- import { createSpiceflowClient } from 'spiceflow/client'
291
- import { Spiceflow } from 'spiceflow'
284
+ import { Spiceflow, json } from 'spiceflow'
292
285
  import { z } from 'zod'
293
286
 
294
- // Define the app with multiple routes and features
295
- const app = new Spiceflow()
296
- .route({
297
- method: 'GET',
298
- path: '/hello/:id',
299
- handler({ params }) {
300
- return `Hello, ${params.id}!`
301
- },
302
- })
303
- .route({
304
- method: 'POST',
305
- path: '/users',
306
- async handler({ request }) {
307
- const body = await request.json() // here body has type { name?: string, email?: string }
308
- return `Created user: ${body.name}`
309
- },
310
- request: z.object({
311
- name: z.string().optional(),
312
- email: z.string().email().optional(),
313
- }),
314
- })
315
- .route({
316
- method: 'GET',
317
- path: '/stream',
318
- async *handler() {
319
- yield 'Start'
320
- await new Promise((resolve) => setTimeout(resolve, 1000))
321
- yield 'Middle'
322
- await new Promise((resolve) => setTimeout(resolve, 1000))
323
- yield 'End'
324
- },
325
- })
287
+ new Spiceflow().route({
288
+ method: 'GET',
289
+ path: '/users/:id',
290
+ response: {
291
+ 200: z.object({ id: z.string(), name: z.string() }),
292
+ 404: z.object({ error: z.string() }),
293
+ },
294
+ handler({ params }) {
295
+ const user = findUser(params.id)
296
+ if (!user) {
297
+ // TypeScript validates: 404 is in the response map, and { error: string } matches the 404 schema
298
+ throw json({ error: 'not found' }, { status: 404 })
299
+ }
300
+ return { id: user.id, name: user.name }
301
+ },
302
+ })
303
+ ```
326
304
 
327
- // Create the client
328
- const client = createSpiceflowClient<typeof app>('http://localhost:3000')
329
-
330
- // Example usage of the client
331
- async function exampleUsage() {
332
- // GET request
333
- const { data: helloData, error: helloError } = await client
334
- .hello({ id: 'World' })
335
- .get()
336
- if (helloError) {
337
- console.error('Error fetching hello:', helloError)
338
- } else {
339
- console.log('Hello response:', helloData)
340
- }
305
+ If you pass a status code that's not in the response map, or a body that doesn't match the schema for that status, `tsc` reports an error:
341
306
 
342
- // POST request
343
- const { data: userData, error: userError } = await client.users.post({
344
- name: 'John Doe',
345
- email: 'john.doe@example.com',
346
- })
347
- if (userError) {
348
- console.error('Error creating user:', userError)
349
- } else {
350
- console.log('User creation response:', userData)
351
- }
307
+ ```ts
308
+ // @ts-expect-error 500 is not in the response schema
309
+ throw json({ error: 'server error' }, { status: 500 })
352
310
 
353
- // Async generator (streaming) request
354
- const { data: streamData, error: streamError } = await client.stream.get()
355
- if (streamError) {
356
- console.error('Error fetching stream:', streamError)
357
- } else {
358
- for await (const chunk of streamData) {
359
- console.log('Stream chunk:', chunk)
360
- }
361
- }
362
- }
311
+ // @ts-expect-error number doesn't match { error: string } for 404
312
+ throw json(42, { status: 404 })
363
313
  ```
364
314
 
365
- ## Fetch Client (Recommended)
315
+ The fetch client picks up these types automatically — each non-200 status becomes a typed `SpiceflowFetchError` with the exact body shape. See [Preserving Client Type Safety](docs/openapi.md#preserving-client-type-safety) for the full client-side pattern.
366
316
 
367
- `createSpiceflowFetch` is the recommended way to interact with a Spiceflow app. It uses a familiar `fetch(path, options)` interface instead of the proxy-based chainable API of `createSpiceflowClient`. It provides the same type safety for paths, params, query, body, and responses, but with a simpler and more predictable API.
317
+ ## Middleware
368
318
 
369
- Export the app type from your server code:
319
+ Middleware functions run before route handlers. They can log, authenticate, modify responses, or short-circuit the request entirely.
370
320
 
371
321
  ```ts
372
- // server.ts
373
322
  import { Spiceflow } from 'spiceflow'
374
- import { z } from 'zod'
375
323
 
376
- const app = new Spiceflow()
377
- .route({
378
- method: 'GET',
379
- path: '/hello',
380
- handler() {
381
- return 'Hello, World!'
382
- },
383
- })
384
- .route({
385
- method: 'POST',
386
- path: '/users',
387
- request: z.object({
388
- name: z.string(),
389
- email: z.string().email(),
390
- }),
391
- async handler({ request }) {
392
- const body = await request.json()
393
- return { id: '1', name: body.name, email: body.email }
394
- },
324
+ new Spiceflow().use(({ request }) => {
325
+ console.log(`Received ${request.method} request to ${request.parsedUrl.pathname}`)
326
+ })
327
+ ```
328
+
329
+ ### Response Modification
330
+
331
+ Call `next()` to get the response from downstream handlers, then modify it before sending:
332
+
333
+ ```ts
334
+ import { Spiceflow } from 'spiceflow'
335
+
336
+ new Spiceflow()
337
+ .use(async ({ request }, next) => {
338
+ const response = await next()
339
+ if (response) {
340
+ // Add a custom header to all responses
341
+ response.headers.set('X-Powered-By', 'Spiceflow')
342
+ }
343
+ return response
395
344
  })
396
345
  .route({
397
346
  method: 'GET',
398
- path: '/users/:id',
399
- handler({ params }) {
400
- return { id: params.id }
347
+ path: '/example',
348
+ handler() {
349
+ return { message: 'Hello, World!' }
401
350
  },
402
351
  })
352
+ ```
353
+
354
+ ### Static Files
355
+
356
+ Use `serveStatic()` to serve files from a directory:
357
+
358
+ ```ts
359
+ import { Spiceflow, serveStatic } from 'spiceflow'
360
+
361
+ export const app = new Spiceflow()
362
+ .use(serveStatic({ root: './public' }))
403
363
  .route({
404
364
  method: 'GET',
405
- path: '/search',
406
- query: z.object({ q: z.string(), page: z.coerce.number().optional() }),
407
- handler({ query }) {
408
- return { results: [], query: query.q, page: query.page }
365
+ path: '/health',
366
+ handler() {
367
+ return { ok: true }
409
368
  },
410
369
  })
411
370
  .route({
412
371
  method: 'GET',
413
- path: '/stream',
414
- async *handler() {
415
- yield 'Start'
416
- yield 'Middle'
417
- yield 'End'
372
+ path: '/*',
373
+ handler() {
374
+ return new Response('Not Found', { status: 404 })
418
375
  },
419
376
  })
420
-
421
- export type App = typeof app
422
377
  ```
423
378
 
424
- Then use the `App` type on the client side without importing server code:
379
+ Static middleware only serves `GET` and `HEAD` requests. It checks the exact file path first, and if the request points to a directory it tries `index.html` inside that directory.
425
380
 
426
- ```ts
427
- // client.ts
428
- import { createSpiceflowFetch } from 'spiceflow/client'
429
- import type { App } from './server'
381
+ <details>
382
+ <summary>Priority rules</summary>
430
383
 
431
- const f = createSpiceflowFetch<App>('http://localhost:3000')
384
+ - Concrete routes win over static files. A route like `/health` is handled by the route even if `public/health` exists.
385
+ - Static files win over root catch-all routes like `/*` and `*`.
386
+ - If static does not find a file, the request falls through to the next matching route.
387
+ - When multiple static middlewares are registered, they are checked in registration order. The first middleware that finds a file wins.
432
388
 
433
- // Returns Error | Data — check with instanceof Error
434
- const greeting = await f('/hello')
435
- if (greeting instanceof Error) return greeting // early return on error
436
- console.log(greeting) // 'Hello, World!' — TypeScript knows the type
389
+ Example behavior:
437
390
 
438
- // POST with typed body
439
- const user = await f('/users', {
440
- method: 'POST',
441
- body: { name: 'John', email: 'john@example.com' },
442
- })
443
- if (user instanceof Error) return user
444
- console.log(user.id, user.name) // fully typed
391
+ ```text
392
+ request /logo.png
393
+ -> router matches `/*`
394
+ -> static checks `public/logo.png`
395
+ -> if file exists, static serves it
396
+ -> otherwise the `/*` route runs
397
+ ```
445
398
 
446
- // Path params type-safe, required when path has :params
447
- const foundUser = await f('/users/:id', {
448
- params: { id: '123' },
449
- })
450
- if (foundUser instanceof Error) return foundUser
399
+ Directory requests without an `index.html` fall through instead of throwing filesystem errors like `EISDIR`.
451
400
 
452
- // Query params — typed from route schema
453
- const searchResults = await f('/search', {
454
- query: { q: 'hello', page: 1 },
455
- })
456
- if (searchResults instanceof Error) return searchResults
401
+ </details>
457
402
 
458
- // Streaming returns AsyncGenerator for async generator routes
459
- const stream = await f('/stream')
460
- if (stream instanceof Error) return stream
461
- for await (const chunk of stream) {
462
- console.log(chunk) // 'Start', 'Middle', 'End'
463
- }
403
+ You can stack multiple static roots:
404
+
405
+ ```ts
406
+ export const app = new Spiceflow()
407
+ .use(serveStatic({ root: './public' }))
408
+ .use(serveStatic({ root: './uploads' }))
464
409
  ```
465
410
 
466
- The fetch client returns `Error | Data` directly following the [errore](https://errore.org) convention — use `instanceof Error` to check for errors with Go-style early returns, then the happy path continues with the narrowed data type. No `{ data, error }` destructuring, no null checks. On error, the returned `SpiceflowFetchError` has `status`, `value` (the parsed error body), and `response` (the raw Response object) properties.
411
+ In this example, `./public/logo.png` wins over `./uploads/logo.png` because `./public` is registered first.
467
412
 
468
- The fetch client supports configuration options like headers, retries, onRequest/onResponse hooks, and custom fetch.
413
+ > Vite client build assets (`dist/client`) are served automatically in production no need to register a `serveStatic` middleware for them.
469
414
 
470
- You can also pass a Spiceflow app instance directly for server-side usage without network requests:
415
+ ### Static Routes (Pre-rendered)
416
+
417
+ Use `.staticGet()` to define API routes that are **pre-rendered at build time** and served as static files. The handler runs once during `vite build`, and the response body is written to `dist/client/` so it can be served directly without hitting the server at runtime:
471
418
 
472
419
  ```ts
473
- const f = createSpiceflowFetch(app)
474
- const greeting = await f('/hello')
475
- if (greeting instanceof Error) throw greeting
420
+ export const app = new Spiceflow()
421
+ .staticGet('/api/manifest.json', () => ({
422
+ name: 'my-app',
423
+ version: '1.0.0',
424
+ features: ['rsc', 'streaming'],
425
+ }))
426
+ .staticGet('/robots.txt', () =>
427
+ new Response('User-agent: *\nAllow: /', {
428
+ headers: { 'content-type': 'text/plain' },
429
+ }),
430
+ )
431
+ ```
432
+
433
+ In development, `staticGet` routes behave like normal `.get()` handlers — the handler runs on every request. At build time, Spiceflow calls each handler and writes the output to disk. The route path should include a file extension (`.json`, `.xml`, `.txt`) so the static file server can detect the correct MIME type.
434
+
435
+ For authorization, proxy, non-blocking auth, cookies, and graceful shutdown patterns, see [Middleware Patterns](docs/middleware-patterns.md).
436
+
437
+ ## Error Handling
438
+
439
+ ```ts
440
+ import { Spiceflow } from 'spiceflow'
441
+
442
+ new Spiceflow().onError(({ error }) => {
443
+ console.error(error)
444
+ return new Response('An error occurred', { status: 500 })
445
+ })
476
446
  ```
477
447
 
478
- ### Path Matching - Supported Features
448
+ ## Async Generators (Streaming)
449
+
450
+ Async generators will create a server sent event response.
451
+
452
+ ```ts
453
+ // server.ts
454
+ import { Spiceflow } from 'spiceflow'
455
+
456
+ export const app = new Spiceflow().route({
457
+ method: 'GET',
458
+ path: '/sseStream',
459
+ async *handler() {
460
+ yield { message: 'Start' }
461
+ await new Promise((resolve) => setTimeout(resolve, 1000))
462
+ yield { message: 'Middle' }
463
+ await new Promise((resolve) => setTimeout(resolve, 1000))
464
+ yield { message: 'End' }
465
+ },
466
+ })
467
+
468
+ export type App = typeof app
469
+ ```
470
+
471
+ Server-Sent Events (SSE) format — the server sends events as `data: {"message":"Start"}\n\n` chunks.
472
+
473
+ ```ts
474
+ // client.ts
475
+ import { createSpiceflowFetch } from 'spiceflow/client'
476
+ import type { App } from './server'
479
477
 
480
- - **Named parameters**: `:param` - Captures dynamic segments like `/users/:id` or `/api/:version/users/:userId`
481
- - **Wildcards**: `*` - Matches any remaining path segments like `/files/*` or `/proxy/*`
482
- - **Catch-all routes**: `/*` - Use as a not-found handler that catches any unmatched paths
478
+ const safeFetch = createSpiceflowFetch<App>('http://localhost:3000')
483
479
 
484
- ### Path Matching - Unsupported Features
480
+ async function fetchStream() {
481
+ const stream = await safeFetch('/sseStream')
482
+ if (stream instanceof Error) {
483
+ console.error('Error fetching stream:', stream.message)
484
+ return
485
+ }
486
+ for await (const chunk of stream) {
487
+ console.log('Stream chunk:', chunk)
488
+ }
489
+ }
485
490
 
486
- - **Optional parameters**: `/:param?` - Use separate routes instead - IS NOT SUPPORTED
487
- - **Named wildcards**: `/files/*name` - Use unnamed `*` only - IS NOT SUPPORTED
488
- - **Partial parameters**: `/:param-suffix` or `/prefix-:param` - Use full segment parameters only - IS NOT SUPPORTED
489
- - **Regex patterns**: `/users/(\\d+)` - Use string parameters with validation in handlers - IS NOT SUPPORTED
490
- - **Multiple wildcards**: `/*/files/*` - Use single wildcard only - IS NOT SUPPORTED
491
+ fetchStream()
492
+ ```
491
493
 
492
494
  ## Not Found Handler
493
495
 
494
- Use `/*` as a catch-all route to handle 404 errors. More specific routes always take precedence regardless of registration order:
496
+ For API routes (`.route()`, `.get()`, etc.), use `/*` as a catch-all to handle unmatched requests. For React pages, use `children === null` in a layout instead (see [Redirects and Not Found](#redirects-and-not-found)). More specific routes always take precedence regardless of registration order:
495
497
 
496
498
  ```ts
497
499
  import { Spiceflow } from 'spiceflow'
498
500
 
499
- const app = new Spiceflow()
501
+ export const app = new Spiceflow()
500
502
  .route({
501
503
  method: 'GET',
502
504
  path: '/users',
@@ -524,7 +526,7 @@ const app = new Spiceflow()
524
526
  method: '*',
525
527
  path: '/*',
526
528
  handler({ request }) {
527
- return new Response(`Cannot ${request.method} ${request.url}`, {
529
+ return new Response(`Cannot ${request.method} ${request.parsedUrl.pathname}`, {
528
530
  status: 404,
529
531
  })
530
532
  },
@@ -536,200 +538,6 @@ const app = new Spiceflow()
536
538
  // GET /unknown returns 'Page not found' with 404 status
537
539
  ```
538
540
 
539
- ## Storing Spiceflow in Class Instances
540
-
541
- If you need to store a Spiceflow router as a property in a class instance, use the `AnySpiceflow` type:
542
-
543
- **Important**: Do not use `this` inside route handlers to reference the parent class. The `this` context inside handlers always refers to the Spiceflow instance, not your class instance. Instead, capture the parent class reference in a variable outside the handlers:
544
-
545
- ```ts
546
- import { Spiceflow, AnySpiceflow } from 'spiceflow'
547
-
548
- export class ChatDurableObject {
549
- private router: AnySpiceflow
550
- private state: DurableObjectState
551
-
552
- constructor(state: DurableObjectState, env: Env) {
553
- this.state = state
554
- const self = this // Capture parent class reference - IMPORTANT!
555
-
556
- this.router = new Spiceflow()
557
- .route({
558
- method: 'GET',
559
- path: '/messages',
560
- async handler() {
561
- // Use 'self' instead of 'this' to access parent class
562
- // this.state would NOT work here - 'this' refers to Spiceflow instance
563
- const messages = (await self.state.storage.get('messages')) || []
564
- return { messages }
565
- },
566
- })
567
- .route({
568
- method: 'POST',
569
- path: '/messages',
570
- async handler({ request }) {
571
- const { message } = await request.json()
572
- // Use 'self' to access parent class properties
573
- const messages = (await self.state.storage.get('messages')) || []
574
- messages.push({ id: Date.now(), text: message })
575
- await self.state.storage.put('messages', messages)
576
- return { success: true }
577
- },
578
- })
579
- }
580
-
581
- fetch(request: Request) {
582
- return this.router.handle(request)
583
- }
584
- }
585
- ```
586
-
587
- ## Safe Path Building
588
-
589
- The `safePath` method provides a type-safe way to build URLs with parameters. It helps prevent runtime errors by ensuring all required parameters are provided and properly substituted into the path.
590
-
591
- ```ts
592
- import { Spiceflow } from 'spiceflow'
593
-
594
- const app = new Spiceflow()
595
- .route({
596
- method: 'GET',
597
- path: '/users/:id',
598
- handler({ params }) {
599
- return { id: params.id }
600
- },
601
- })
602
- .route({
603
- method: 'GET',
604
- path: '/users/:id/posts/:postId',
605
- handler({ params }) {
606
- return { userId: params.id, postId: params.postId }
607
- },
608
- })
609
-
610
- // Building URLs with required parameters
611
- const userPath = app.safePath('/users/:id', { id: '123' })
612
- // Result: '/users/123'
613
-
614
- // Building URLs with required parameters
615
- const userPostPath = app.safePath('/users/:id/posts/:postId', {
616
- id: '456',
617
- postId: 'abc',
618
- })
619
- // Result: '/users/456/posts/abc'
620
- ```
621
-
622
- ### Query Parameters
623
-
624
- When a route has a `query` schema, `safePath` accepts query parameters alongside path parameters in the same flat object. Query parameters are appended as a query string, and unknown keys are rejected at the type level:
625
-
626
- ```ts
627
- const app = new Spiceflow()
628
- .route({
629
- method: 'GET',
630
- path: '/search',
631
- query: z.object({ q: z.string(), page: z.coerce.number() }),
632
- handler({ query }) {
633
- return { results: [], q: query.q }
634
- },
635
- })
636
- .route({
637
- method: 'GET',
638
- path: '/users/:id',
639
- query: z.object({ fields: z.string() }),
640
- handler({ params, query }) {
641
- return { id: params.id, fields: query.fields }
642
- },
643
- })
644
-
645
- app.safePath('/search', { q: 'hello', page: 1 })
646
- // Result: '/search?q=hello&page=1'
647
-
648
- app.safePath('/users/:id', { id: '42', fields: 'name' })
649
- // Result: '/users/42?fields=name'
650
-
651
- // @ts-expect-error - 'invalid' is not a known query key
652
- app.safePath('/search', { invalid: 'x' })
653
- ```
654
-
655
- ### Standalone `createSafePath`
656
-
657
- If you need a path builder on the client side where you can't import server app code, use `createSafePath` with the `typeof app` generic:
658
-
659
- ```ts
660
- import { createSafePath } from 'spiceflow'
661
- import type { App } from './server' // import only the type, not the runtime app
662
-
663
- const safePath = createSafePath<App>()
664
-
665
- safePath('/users/:id', { id: '123' })
666
- // Result: '/users/123'
667
-
668
- safePath('/search', { q: 'hello', page: 1 })
669
- // Result: '/search?q=hello&page=1'
670
- ```
671
-
672
- The returned function has the same type safety as `app.safePath` — it infers paths, params, and query schemas from the app type. The app argument is optional and not used at runtime, so you can call `createSafePath<App>()` without passing any value.
673
-
674
- ### OAuth Callback Example
675
-
676
- The `safePath` method is particularly useful when building callback URLs for OAuth flows, where you need to construct URLs dynamically based on user data or session information:
677
-
678
- ```ts
679
- import { Spiceflow } from 'spiceflow'
680
-
681
- const app = new Spiceflow()
682
- .route({
683
- method: 'GET',
684
- path: '/auth/callback/:provider/:userId',
685
- handler({ params, query }) {
686
- const { provider, userId } = params
687
- const { code, state } = query
688
-
689
- // Handle OAuth callback logic here
690
- return {
691
- provider,
692
- userId,
693
- authCode: code,
694
- state,
695
- }
696
- },
697
- })
698
- .route({
699
- method: 'POST',
700
- path: '/auth/login',
701
- handler({ request }) {
702
- const userId = '12345'
703
- const provider = 'google'
704
-
705
- // Build the OAuth callback URL safely
706
- const callbackUrl = new URL(
707
- app.safePath('/auth/callback/:provider/:userId', {
708
- provider,
709
- userId,
710
- }),
711
- 'https://myapp.com',
712
- ).toString()
713
-
714
- // Redirect to OAuth provider with callback URL
715
- const oauthUrl =
716
- `https://accounts.google.com/oauth/authorize?` +
717
- `client_id=your-client-id&` +
718
- `redirect_uri=${encodeURIComponent(callbackUrl)}&` +
719
- `response_type=code&` +
720
- `scope=openid%20profile%20email`
721
-
722
- return { redirectUrl: oauthUrl }
723
- },
724
- })
725
- ```
726
-
727
- In this example:
728
-
729
- - The callback URL is built safely using `safePath` with type checking
730
- - Required parameters like `provider` and `userId` must be provided
731
- - The resulting URL is guaranteed to be properly formatted
732
-
733
541
  ## Mounting Sub-Apps
734
542
 
735
543
  ```ts
@@ -760,10 +568,12 @@ const mainApp = new Spiceflow()
760
568
 
761
569
  ## Base Path
762
570
 
571
+ For standalone API servers (without Vite), set the base path in the constructor:
572
+
763
573
  ```ts
764
574
  import { Spiceflow } from 'spiceflow'
765
575
 
766
- const app = new Spiceflow({ basePath: '/api/v1' })
576
+ export const app = new Spiceflow({ basePath: '/api/v1' })
767
577
  app.route({
768
578
  method: 'GET',
769
579
  path: '/hello',
@@ -773,240 +583,242 @@ app.route({
773
583
  }) // Accessible at /api/v1/hello
774
584
  ```
775
585
 
776
- ## Async Generators (Streaming)
586
+ ### Vite Base Path
777
587
 
778
- Async generators will create a server sent event response.
588
+ When using Spiceflow as a full-stack RSC framework with Vite, configure the base path via Vite's `base` option instead of the constructor:
779
589
 
780
590
  ```ts
781
- import { Spiceflow } from 'spiceflow'
782
-
783
- const app = new Spiceflow().route({
784
- method: 'GET',
785
- path: '/sseStream',
786
- async *handler() {
787
- yield { message: 'Start' }
788
- await new Promise((resolve) => setTimeout(resolve, 1000))
789
- yield { message: 'Middle' }
790
- await new Promise((resolve) => setTimeout(resolve, 1000))
791
- yield { message: 'End' }
792
- },
591
+ // vite.config.ts
592
+ import react from '@vitejs/plugin-react'
593
+ import { defineConfig } from 'vite'
594
+ import spiceflow from 'spiceflow/vite'
595
+
596
+ export default defineConfig({
597
+ base: '/my-app',
598
+ plugins: [react(), spiceflow({ entry: 'src/main.tsx' })],
793
599
  })
600
+ ```
794
601
 
795
- // Server-Sent Events (SSE) format
796
- // The server will send events in the following format:
797
- // data: {"message":"Start"}
798
- // data: {"message":"Middle"}
799
- // data: {"message":"End"}
800
-
801
- // Example response output:
802
- // data: {"message":"Start"}
803
- // data: {"message":"Middle"}
804
- // data: {"message":"End"}
805
-
806
- // Client usage example with RPC client
807
- import { createSpiceflowClient } from 'spiceflow/client'
808
-
809
- const client = createSpiceflowClient<typeof app>('http://localhost:3000')
602
+ <details>
603
+ <summary>Base path rules</summary>
810
604
 
811
- async function fetchStream() {
812
- const response = await client.sseStream.get()
813
- if (response.error) {
814
- console.error('Error fetching stream:', response.error)
815
- } else {
816
- for await (const chunk of response.data) {
817
- console.log('Stream chunk:', chunk)
818
- }
819
- }
820
- }
605
+ The base path must be an absolute path starting with `/`. CDN URLs and relative paths are not supported.
821
606
 
822
- fetchStream()
823
- ```
607
+ Do not set `basePath` in the Spiceflow constructor when using Vite — Spiceflow will throw an error if both are set. The Vite `base` option is the single source of truth.
824
608
 
825
- ## Error Handling
609
+ </details>
826
610
 
827
- ```ts
828
- import { Spiceflow } from 'spiceflow'
611
+ <details>
612
+ <summary>What gets auto-prepended and what doesn't</summary>
829
613
 
830
- new Spiceflow().onError(({ error }) => {
831
- console.error(error)
832
- return new Response('An error occurred', { status: 500 })
833
- })
834
- ```
614
+ **What gets the base path auto-prepended:**
835
615
 
836
- ## Middleware
616
+ - `Link` component `href` — `<Link href="/dashboard" />` automatically renders as `<a href="/my-app/dashboard">`. If the href already includes the base prefix, it is not added again (`<Link href="/my-app/dashboard" />` stays as-is). To disable auto-prepending entirely, use the `rawHref` prop: `<Link rawHref href="/docs/docs" />` — useful when your path legitimately starts with the same string as the base
617
+ - `redirect()` Location header — `redirect("/login")` sends `Location: /my-app/login`
618
+ - `router.push()` and `router.replace()` — `router.push("/settings")` navigates to `/my-app/settings`
619
+ - `router.pathname` — returns the path **without** the base prefix (e.g. `/dashboard`, not `/my-app/dashboard`)
620
+ - Static asset URLs (`<script>`, `<link>` CSS tags) — handled automatically by Vite
621
+ - `serveStatic` file resolution — strips the base prefix before looking up files on disk
837
622
 
838
- ```ts
839
- import { Spiceflow } from 'spiceflow'
623
+ **What does NOT get auto-prepended:**
840
624
 
841
- new Spiceflow().use(({ request }) => {
842
- console.log(`Received ${request.method} request to ${request.url}`)
843
- })
844
- ```
625
+ - Raw `<a href="/path">` tags (not using the `Link` component) use `Link` instead
626
+ - External URLs and protocol-relative URLs (`//cdn.com/...`) — left as-is
627
+ - `fetch()` calls inside your app code — you need to construct the URL yourself
628
+ - `request.url` and `request.parsedUrl` in middleware — contain the full URL including the base prefix
845
629
 
846
- ## How errors are handled in Spiceflow client
630
+ </details>
847
631
 
848
- The Spiceflow client provides type-safe error handling by returning either a `data` or `error` property. When using the client:
632
+ ## Fetch Client
849
633
 
850
- - Thrown errors appear in the `error` field
851
- - Response objects can be thrown or returned
852
- - Responses with status codes 200-299 appear in the `data` field
853
- - Responses with status codes < 200 or ≥ 300 appear in the `error` field
634
+ `createSpiceflowFetch` provides a type-safe `fetch(path, options)` interface for calling your Spiceflow API. It gives you full type safety on **path params**, **query params**, **request body**, and **response data** — all inferred from your route definitions.
854
635
 
855
- The example below demonstrates handling different types of responses:
636
+ Export the app type from your server code:
856
637
 
857
638
  ```ts
639
+ // server.ts
858
640
  import { Spiceflow } from 'spiceflow'
859
- import { createSpiceflowClient } from 'spiceflow/client'
641
+ import { z } from 'zod'
860
642
 
861
- const app = new Spiceflow()
643
+ export const app = new Spiceflow()
862
644
  .route({
863
645
  method: 'GET',
864
- path: '/error',
646
+ path: '/hello',
865
647
  handler() {
866
- throw new Error('Something went wrong')
648
+ return 'Hello, World!'
649
+ },
650
+ })
651
+ .route({
652
+ method: 'POST',
653
+ path: '/users',
654
+ request: z.object({
655
+ name: z.string(),
656
+ email: z.string().email(),
657
+ }),
658
+ async handler({ request }) {
659
+ const body = await request.json()
660
+ return { id: '1', name: body.name, email: body.email }
867
661
  },
868
662
  })
869
663
  .route({
870
664
  method: 'GET',
871
- path: '/unauthorized',
872
- handler() {
873
- return new Response('Unauthorized access', { status: 401 })
665
+ path: '/users/:id',
666
+ handler({ params }) {
667
+ return { id: params.id }
874
668
  },
875
669
  })
876
670
  .route({
877
671
  method: 'GET',
878
- path: '/success',
879
- handler() {
880
- throw new Response('Success message', { status: 200 })
881
- return ''
672
+ path: '/search',
673
+ query: z.object({ q: z.string(), page: z.coerce.number().optional() }),
674
+ handler({ query }) {
675
+ return { results: [], query: query.q, page: query.page }
882
676
  },
883
677
  })
884
-
885
- const client = createSpiceflowClient<typeof app>('http://localhost:3000')
886
-
887
- async function handleErrors() {
888
- const errorResponse = await client.error.get()
889
- console.log('Calling error endpoint...')
890
- // Logs: Error occurred: Something went wrong
891
- if (errorResponse.error) {
892
- console.error('Error occurred:', errorResponse.error)
893
- }
894
-
895
- const unauthorizedResponse = await client.unauthorized.get()
896
- console.log('Calling unauthorized endpoint...')
897
- // Logs: Unauthorized: Unauthorized access (Status: 401)
898
- if (unauthorizedResponse.error) {
899
- console.error('Unauthorized:', unauthorizedResponse.error)
900
- }
901
-
902
- const successResponse = await client.success.get()
903
- console.log('Calling success endpoint...')
904
- // Logs: Success: Success message
905
- if (successResponse.data) {
906
- console.log('Success:', successResponse.data)
907
- }
678
+ .route({
679
+ method: 'GET',
680
+ path: '/stream',
681
+ async *handler() {
682
+ yield 'Start'
683
+ yield 'Middle'
684
+ yield 'End'
685
+ },
686
+ })
687
+
688
+ export type App = typeof app
689
+ ```
690
+
691
+ Then use the `App` type on the client side without importing server code:
692
+
693
+ ```ts
694
+ // client.ts
695
+ import { createSpiceflowFetch } from 'spiceflow/client'
696
+ import type { App } from './server'
697
+
698
+ const safeFetch = createSpiceflowFetch<App>('http://localhost:3000')
699
+
700
+ // GET request — returns Error | Data, check with instanceof Error
701
+ const greeting = await safeFetch('/hello')
702
+ if (greeting instanceof Error) return greeting
703
+ console.log(greeting) // 'Hello, World!' — TypeScript knows the type
704
+
705
+ // POST with typed body — TypeScript requires { name: string, email: string }
706
+ const user = await safeFetch('/users', {
707
+ method: 'POST',
708
+ body: { name: 'John', email: 'john@example.com' },
709
+ })
710
+ if (user instanceof Error) return user
711
+ console.log(user.id, user.name, user.email) // fully typed
712
+
713
+ // Path params — type-safe, TypeScript requires { id: string }
714
+ const foundUser = await safeFetch('/users/:id', {
715
+ params: { id: '123' },
716
+ })
717
+ if (foundUser instanceof Error) return foundUser
718
+ console.log(foundUser.id) // typed as string
719
+
720
+ // Query params — typed from the route's Zod schema
721
+ const searchResults = await safeFetch('/search', {
722
+ query: { q: 'hello', page: 1 },
723
+ })
724
+ if (searchResults instanceof Error) return searchResults
725
+ console.log(searchResults.results, searchResults.query) // fully typed
726
+
727
+ // Streaming — async generator routes return an AsyncGenerator
728
+ const stream = await safeFetch('/stream')
729
+ if (stream instanceof Error) return stream
730
+ for await (const chunk of stream) {
731
+ console.log(chunk) // 'Start', 'Middle', 'End'
908
732
  }
909
733
  ```
910
734
 
911
- ## Using the client server side, without network requests
735
+ The fetch client returns `Error | Data` directly following the [errore](https://errore.org) convention — use `instanceof Error` to check for errors with Go-style early returns, then the happy path continues with the narrowed data type. No `{ data, error }` destructuring, no null checks. On error, the returned `SpiceflowFetchError` has `status`, `value` (the parsed error body), and `response` (the raw Response object) properties.
912
736
 
913
- When using the client server-side, you can pass the Spiceflow app instance directly to `createSpiceflowClient()` instead of providing a URL. This allows you to make "virtual" requests that are handled directly by the app without making actual network requests. This is useful for testing, generating documentation, or any other scenario where you want to interact with your API endpoints programmatically without setting up a server.
737
+ The fetch client supports configuration options like headers, retries, onRequest/onResponse hooks, and custom fetch.
914
738
 
915
- Here's an example:
739
+ You can also pass a Spiceflow app instance directly for server-side usage without network requests:
740
+
741
+ ```ts
742
+ const safeFetch = createSpiceflowFetch(app)
743
+ const greeting = await safeFetch('/hello')
744
+ if (greeting instanceof Error) throw greeting
745
+ ```
746
+
747
+ For path matching patterns, error handling, server-side fetch, type-safe RPC, and path building (`href` / `createHref`), see [Fetch Client docs](docs/fetch-client.md).
748
+
749
+ ## Cloudflare Bindings
750
+
751
+ On Cloudflare Workers, the simplest way to read bindings is to import `env` directly from `cloudflare:workers`. Run `wrangler types` after changing `wrangler.jsonc` so Wrangler regenerates `worker-configuration.d.ts` — that gives `env` a type-safe `Env` shape automatically.
916
752
 
917
753
  ```tsx
918
754
  import { Spiceflow } from 'spiceflow'
919
- import { createSpiceflowClient } from 'spiceflow/client'
920
- import { openapi } from 'spiceflow/openapi'
921
- import { writeFile } from 'node:fs/promises'
755
+ import { env } from 'cloudflare:workers'
922
756
 
923
- const app = new Spiceflow()
924
- .use(openapi({ path: '/openapi' }))
757
+ export const app = new Spiceflow()
925
758
  .route({
926
759
  method: 'GET',
927
- path: '/users',
928
- handler() {
929
- return [
930
- { id: 1, name: 'John' },
931
- { id: 2, name: 'Jane' },
932
- ]
760
+ path: '/kv/:key',
761
+ async handler({ params }) {
762
+ const value = await env.KV.get(params.key)
763
+ return { key: params.key, value }
933
764
  },
934
765
  })
935
766
  .route({
936
767
  method: 'POST',
937
- path: '/users',
938
- handler({ request }) {
939
- return request.json()
768
+ path: '/queue',
769
+ async handler({ request }) {
770
+ const body = await request.json()
771
+ await env.QUEUE.send(body)
772
+ return { success: true, message: 'Added to queue' }
940
773
  },
941
774
  })
942
775
 
943
- // Create client by passing app instance directly
944
- const client = createSpiceflowClient(app)
945
-
946
- // Get OpenAPI schema and write to disk
947
- const { data } = await client.openapi.get()
948
- await writeFile('openapi.json', JSON.stringify(data, null, 2))
949
- console.log('OpenAPI schema saved to openapi.json')
776
+ export default {
777
+ fetch(request: Request) {
778
+ return app.handle(request)
779
+ },
780
+ }
950
781
  ```
951
782
 
952
- ## Modifying Response with Middleware
783
+ ## Cookies
953
784
 
954
- Middleware in Spiceflow can be used to modify the response before it's sent to the client. This is useful for adding headers, transforming the response body, or performing any other operations on the response.
785
+ Spiceflow works with standard Request and Response objects, so you can use any cookie library like the `cookie` npm package. See [Middleware Patterns](docs/middleware-patterns.md) for full cookie examples including set/get/clear and cookie-based auth middleware.
955
786
 
956
- Here's an example of how to modify the response using middleware:
957
-
958
- ```ts
959
- import { Spiceflow } from 'spiceflow'
960
-
961
- new Spiceflow()
962
- .use(async ({ request }, next) => {
963
- const response = await next()
964
- if (response) {
965
- // Add a custom header to all responses
966
- response.headers.set('X-Powered-By', 'Spiceflow')
967
- }
968
- return response
969
- })
970
- .route({
971
- method: 'GET',
972
- path: '/example',
973
- handler() {
974
- return { message: 'Hello, World!' }
975
- },
976
- })
977
- ```
787
+ ## OpenAPI
978
788
 
979
- ## Generating OpenAPI Schema
789
+ Spiceflow can generate a full OpenAPI 3.1 document from your routes without any extra configuration. Mount the `openapi` plugin and every route you registered on the app is picked up automatically — the same Zod schemas that validate the request and type the handler context are also the source of `parameters`, `requestBody`, and `responses` in the emitted document.
980
790
 
981
791
  ```ts
982
792
  import { openapi } from 'spiceflow/openapi'
983
793
  import { Spiceflow } from 'spiceflow'
984
794
  import { z } from 'zod'
985
795
 
986
- const app = new Spiceflow()
796
+ export const app = new Spiceflow()
987
797
  .use(openapi({ path: '/openapi.json' }))
988
798
  .route({
989
799
  method: 'GET',
990
800
  path: '/hello',
991
- handler() {
992
- return 'Hello, World!'
993
- },
994
801
  query: z.object({
995
802
  name: z.string(),
996
803
  age: z.number(),
997
804
  }),
998
805
  response: z.string(),
806
+ handler({ query }) {
807
+ return `Hello, ${query.name}!`
808
+ },
999
809
  })
1000
810
  .route({
1001
811
  method: 'POST',
1002
812
  path: '/user',
1003
- handler() {
1004
- return new Response('Hello, World!')
1005
- },
1006
813
  request: z.object({
1007
814
  name: z.string(),
1008
815
  email: z.string().email(),
1009
816
  }),
817
+ response: z.object({ id: z.string() }),
818
+ async handler({ request }) {
819
+ const body = await request.json()
820
+ return { id: 'usr_' + body.name }
821
+ },
1010
822
  })
1011
823
 
1012
824
  const openapiSchema = await (
@@ -1014,13 +826,15 @@ const openapiSchema = await (
1014
826
  ).json()
1015
827
  ```
1016
828
 
829
+ For status-code response maps, centralized error responses with `onError`, shared Zod schemas across routes, hiding internal routes from the document, writing markdown descriptions with `string-dedent`, generating a local `openapi.json` file from a script, and preserving fetch client type safety with thrown error responses, see [OpenAPI docs](docs/openapi.md).
830
+
1017
831
  ## Adding CORS Headers
1018
832
 
1019
833
  ```ts
1020
834
  import { cors } from 'spiceflow/cors'
1021
835
  import { Spiceflow } from 'spiceflow'
1022
836
 
1023
- const app = new Spiceflow().use(cors()).route({
837
+ export const app = new Spiceflow().use(cors()).route({
1024
838
  method: 'GET',
1025
839
  path: '/hello',
1026
840
  handler() {
@@ -1029,615 +843,1396 @@ const app = new Spiceflow().use(cors()).route({
1029
843
  })
1030
844
  ```
1031
845
 
1032
- ## Proxy requests
846
+ ## Background Tasks (`waitUntil`)
847
+
848
+ Spiceflow provides a `waitUntil` function in the handler context for scheduling background tasks in a cross-platform way. It uses Cloudflare Workers' `waitUntil` if present, and is a no-op in Node.js. See [Cloudflare docs](docs/cloudflare.md#background-tasks-waituntil) for full examples including Cloudflare integration and custom implementations.
849
+
850
+ ## Server Lifecycle
851
+
852
+ `listen()` returns an object with `port`, `server`, and `stop()` for programmatic control:
1033
853
 
1034
854
  ```ts
1035
- import { Spiceflow } from 'spiceflow'
1036
- import { MiddlewareHandler } from 'spiceflow/dist/types'
855
+ const listener = await app.listen(3000)
1037
856
 
1038
- const app = new Spiceflow()
857
+ console.log(`Listening on port ${listener.port}`)
1039
858
 
1040
- function createProxyMiddleware({
1041
- target,
1042
- changeOrigin = false,
1043
- }): MiddlewareHandler {
1044
- return async ({ request }) => {
1045
- const url = new URL(request.url)
859
+ await listener.stop()
860
+ ```
1046
861
 
1047
- const proxyReq = new Request(
1048
- new URL(url.pathname + url.search, target),
1049
- request,
1050
- )
862
+ > In Vite dev and during prerender, Spiceflow skips starting a real server. `listen()` still returns an object, but `port` and `server` are `undefined` and `stop()` is a noop, so cleanup code can stay unconditional.
1051
863
 
1052
- if (changeOrigin) {
1053
- proxyReq.headers.set('origin', new URL(target).origin || '')
1054
- }
1055
- console.log('proxying', proxyReq.url)
1056
- const res = await fetch(proxyReq)
864
+ ## Graceful Shutdown
1057
865
 
1058
- return res
1059
- }
1060
- }
866
+ The `preventProcessExitIfBusy` middleware prevents platforms like Fly.io from killing your app while processing long requests. See [Middleware Patterns](docs/middleware-patterns.md#graceful-shutdown) for usage.
1061
867
 
1062
- app.use(
1063
- createProxyMiddleware({
1064
- target: 'https://api.openai.com',
1065
- changeOrigin: true,
1066
- }),
1067
- )
1068
-
1069
- // or with a basePath
1070
- app.use(
1071
- new Spiceflow({ basePath: '/v1/completions' }).use(
1072
- createProxyMiddleware({
1073
- target: 'https://api.openai.com',
1074
- changeOrigin: true,
1075
- }),
1076
- ),
1077
- )
868
+ ## Tracing (OpenTelemetry)
869
+
870
+ Spiceflow has built-in OpenTelemetry tracing. Pass a `tracer` to the constructor and every request gets automatic spans for middleware, handlers, loaders, layouts, pages, and RSC serialization — no monkey-patching, no plugins. Zero overhead when disabled. Handlers can also read `traceId` and `spanId` from `span.spanContext?.()` when the tracer supports it. See [Tracing docs](docs/tracing.md) for setup, span trees, custom spans, and examples.
871
+
872
+ ## React Framework (RSC)
873
+
874
+ Spiceflow includes a full-stack React framework built on React Server Components (RSC). It uses Vite with `@vitejs/plugin-rsc` under the hood. Server components run on the server by default, and you use `"use client"` to mark interactive components that need to run in the browser.
875
+
876
+ ### Setup
1078
877
 
1079
- app.listen(3030)
878
+ Install the dependencies and create a Vite config:
879
+
880
+ ```bash
881
+ npm install spiceflow@rsc react react-dom
882
+ ```
883
+
884
+ ```ts
885
+ // vite.config.ts
886
+ import react from '@vitejs/plugin-react'
887
+ import { defineConfig } from 'vite'
888
+ import spiceflow from 'spiceflow/vite'
889
+
890
+ export default defineConfig({
891
+ plugins: [
892
+ react(),
893
+ spiceflow({
894
+ entry: './src/main.tsx',
895
+ }),
896
+ ],
897
+ })
1080
898
  ```
1081
899
 
1082
- ### Authorization Middleware
900
+ ### Cloudflare RSC Setup
901
+
902
+ For Cloudflare Workers deployment with RSC, see [Cloudflare docs](docs/cloudflare.md). See [`example-cloudflare/`](example-cloudflare) for a complete working example.
1083
903
 
1084
- You can handle authorization in a middleware, for example here the code checks if the user is logged in and if not, it throws an error. You can use the state to track request data, in this case the state keeps a reference to the session.
904
+ ### Tailwind CSS
905
+
906
+ Install `@tailwindcss/vite` and `tailwindcss`, then add the Vite plugin:
907
+
908
+ ```bash
909
+ npm install @tailwindcss/vite tailwindcss
910
+ ```
1085
911
 
1086
912
  ```ts
1087
- import { z } from 'zod'
913
+ // vite.config.ts
914
+ import react from '@vitejs/plugin-react'
915
+ import tailwindcss from '@tailwindcss/vite'
916
+ import { defineConfig } from 'vite'
917
+ import spiceflow from 'spiceflow/vite'
918
+
919
+ export default defineConfig({
920
+ plugins: [
921
+ spiceflow({ entry: './src/main.tsx' }),
922
+ react(),
923
+ tailwindcss(),
924
+ ],
925
+ })
926
+ ```
927
+
928
+ Create a `globals.css` file with Tailwind and any CSS variables you need:
929
+
930
+ ```css
931
+ /* src/globals.css */
932
+ @import 'tailwindcss';
933
+
934
+ :root {
935
+ --radius: 0.625rem;
936
+ --background: var(--color-white);
937
+ --foreground: var(--color-neutral-800);
938
+ }
939
+ ```
940
+
941
+ Import it at the top of your app entry so styles apply globally:
942
+
943
+ ```tsx
944
+ // src/main.tsx
945
+ import './globals.css'
1088
946
  import { Spiceflow } from 'spiceflow'
1089
947
 
1090
- new Spiceflow()
1091
- .state('session', null as Session | null)
1092
- .use(async ({ request: req, state }, next) => {
1093
- const res = new Response()
948
+ export const app = new Spiceflow()
949
+ .layout('/*', async ({ children }) => {
950
+ return (
951
+ <html>
952
+ <body className="bg-white dark:bg-gray-900 text-black dark:text-white">
953
+ {children}
954
+ </body>
955
+ </html>
956
+ )
957
+ })
958
+ .page('/', async () => {
959
+ return (
960
+ <div className="flex flex-col items-center gap-4 p-8">
961
+ <h1 className="text-4xl font-bold">Welcome</h1>
962
+ </div>
963
+ )
964
+ })
965
+ ```
1094
966
 
1095
- const { session } = await getSession({ req, res })
1096
- if (!session) {
1097
- return
1098
- }
1099
- state.session = session
1100
- const response = await next()
967
+ ### shadcn/ui
1101
968
 
1102
- const cookies = res.headers.getSetCookie()
1103
- for (const cookie of cookies) {
1104
- response.headers.append('Set-Cookie', cookie)
1105
- }
969
+ Spiceflow works with [shadcn/ui](https://ui.shadcn.com) out of the box. Instead of the usual `tsconfig.json` paths hack (`@/*`), use `package.json` `exports` for component imports — it's a standard Node.js feature that works across runtimes and lets other workspace packages import your components too. See [shadcn docs](docs/shadcn.md) for the full setup guide and [`example-shadcn/`](example-shadcn) for a working example.
1106
970
 
1107
- return response
971
+ ### App Entry
972
+
973
+ The entry file defines your routes using `.page()` for pages and `.layout()` for layouts. This file runs in the RSC environment on the server. All routes registered with `.page()`, `.get()`, etc. are available via `getRouter<App>().href()` for type-safe URL building — including path params and query params.
974
+
975
+ ```tsx
976
+ // src/main.tsx
977
+ import { Spiceflow, serveStatic } from 'spiceflow'
978
+ import { getRouter, Head, Link } from 'spiceflow/react'
979
+ import { z } from 'zod'
980
+ import { Counter } from './app/counter'
981
+ import { Nav } from './app/nav'
982
+
983
+ export const app = new Spiceflow()
984
+ .use(serveStatic({ root: './public' }))
985
+ .layout('/*', async ({ children }) => {
986
+ return (
987
+ <html>
988
+ <Head>
989
+ <Head.Meta charSet="UTF-8" />
990
+ </Head>
991
+ <body>
992
+ <Nav />
993
+ {children}
994
+ </body>
995
+ </html>
996
+ )
1108
997
  })
1109
- .route({
1110
- method: 'POST',
1111
- path: '/protected',
1112
- async handler({ state }) {
1113
- const { session } = state
1114
- if (!session) {
1115
- throw new Error('Not logged in')
1116
- }
1117
- return { ok: true }
998
+ .page('/', async () => {
999
+ const router = getRouter<App>()
1000
+ const data = await fetchSomeData()
1001
+ return (
1002
+ <div>
1003
+ <h1>Welcome</h1>
1004
+ <p>Server-rendered data: {data.message}</p>
1005
+ <Counter />
1006
+ <Link href={router.href('/users/:id', { id: '42' })}>View User 42</Link>
1007
+ <Link href={router.href('/search', { q: 'spiceflow' })}>Search</Link>
1008
+ </div>
1009
+ )
1010
+ })
1011
+ .page('/about', async () => {
1012
+ const router = getRouter<App>()
1013
+ return (
1014
+ <div>
1015
+ <h1>About</h1>
1016
+ <Link href={router.href('/')}>Back to Home</Link>
1017
+ </div>
1018
+ )
1019
+ })
1020
+ .page('/users/:id', async ({ params }) => {
1021
+ return (
1022
+ <div>
1023
+ <h1>User {params.id}</h1>
1024
+ </div>
1025
+ )
1026
+ })
1027
+ // Object-style .page() with query schema — enables type-safe query params
1028
+ .page({
1029
+ path: '/search',
1030
+ query: z.object({ q: z.string(), page: z.number().optional() }),
1031
+ handler: async ({ query }) => {
1032
+ const results = await search(query.q, query.page)
1033
+ return (
1034
+ <div>
1035
+ <h1>Results for "{query.q}"</h1>
1036
+ {results.map((r) => (
1037
+ <p key={r.id}>{r.title}</p>
1038
+ ))}
1039
+ </div>
1040
+ )
1118
1041
  },
1119
1042
  })
1043
+ .listen(3000)
1044
+
1045
+ // Export the app type for use in client components
1046
+ export type App = typeof app
1120
1047
  ```
1121
1048
 
1122
- ## Non blocking authentication middleware
1049
+ `getRouter<App>().href()` gives you **type-safe links** — TypeScript validates that the path exists, params are correct, and query values match the schema. Invalid paths or missing params are caught at compile time. `getRouter<App>()` works in both server and client components.
1123
1050
 
1124
- Sometimes authentication is only required for specific routes, and you don't want to block public routes while waiting for authentication. You can use `Promise.withResolvers()` to start fetching user data in parallel, allowing public routes to respond immediately while protected routes wait for authentication to complete.
1051
+ <details>
1052
+ <summary>Why not app.href() inside the chain?</summary>
1125
1053
 
1126
- The example below demonstrates this pattern - the `/public` route responds instantly while `/protected` waits for authentication:
1054
+ Using `app.href()` inside page/layout handlers in the chain definition causes TypeScript error TS7022 — `app` references itself during construction, creating circular type inference. Use `getRouter<App>()` instead, which resolves the router at request time when `app` is fully constructed. `app.href()` still works in standalone functions defined after the chain, but `getRouter<App>()` is the recommended pattern everywhere.
1127
1055
 
1128
- ```ts
1129
- import { Spiceflow } from 'spiceflow'
1056
+ </details>
1130
1057
 
1131
- new Spiceflow()
1132
- .state('userId', Promise.resolve(''))
1133
- .state('userEmail', Promise.resolve(''))
1134
- .use(async ({ request, state }, next) => {
1135
- const sessionKey = request.headers.get('sessionKey')
1136
- const userIdPromise = Promise.withResolvers<string>()
1137
- const userEmailPromise = Promise.withResolvers<string>()
1138
-
1139
- state.userId = userIdPromise.promise
1140
- state.userEmail = userEmailPromise.promise
1141
-
1142
- async function resolveUser() {
1143
- if (!sessionKey) {
1144
- userIdPromise.resolve('')
1145
- userEmailPromise.resolve('')
1146
- return
1147
- }
1148
- const user = await getUser(sessionKey)
1149
- userIdPromise.resolve(user?.id ?? '')
1150
- userEmailPromise.resolve(user?.email ?? '')
1151
- }
1058
+ ### Layouts
1059
+
1060
+ Define a root `.layout('/*', ...)` with the document shell (`<html>`, `<head>`, `<body>`). More specific layouts should only return shared parent UI like sidebars, nav, or section chrome — not another `<html>` shell. Wildcard layouts also match their base path, so `/app/*` wraps both `/app` and `/app/settings`.
1152
1061
 
1153
- resolveUser()
1062
+ ```tsx
1063
+ export const app = new Spiceflow()
1064
+ .layout('/*', async ({ children }) => {
1065
+ return (
1066
+ <html>
1067
+ <body>{children}</body>
1068
+ </html>
1069
+ )
1154
1070
  })
1155
- .route({
1156
- method: 'GET',
1157
- path: '/protected',
1158
- async handler({ state }) {
1159
- const userId = await state.userId
1160
- if (!userId) throw new Error('Not authenticated')
1161
- return { message: 'Protected data' }
1071
+ .layout('/app/*', async ({ children }) => {
1072
+ return <section className="app-shell">{children}</section>
1073
+ })
1074
+ .layout('/docs/*', async ({ children }) => {
1075
+ return <section className="docs-shell">{children}</section>
1076
+ })
1077
+ .page('/app', async () => {
1078
+ return <h1>App home</h1>
1079
+ })
1080
+ .page('/app/settings', async () => {
1081
+ return <h1>App settings</h1>
1082
+ })
1083
+ .page('/docs', async () => {
1084
+ return <h1>Docs home</h1>
1085
+ })
1086
+ .page('/docs/getting-started', async () => {
1087
+ return <h1>Getting started</h1>
1088
+ })
1089
+ ```
1090
+
1091
+ <details>
1092
+ <summary>Nesting rules</summary>
1093
+
1094
+ Only the root layout should render the full HTML document shell. If a nested layout also renders `<html>`, the shell repeats and you end up nesting full HTML documents inside each other. Only add scoped layouts when many pages share the same parent components.
1095
+
1096
+ </details>
1097
+
1098
+ ### SEO
1099
+
1100
+ Use `<Head>`, `<Head.Title>`, and `<Head.Meta>` from `spiceflow/react` for type-safe, automatically deduplicated head tags that are correctly injected during SSR. Page tags override layout tags with the same key.
1101
+
1102
+ Every page should have a `<Head.Title>` and a `<Head.Meta name="description">`. These are the two most important tags for SEO — they control what appears in search engine results.
1103
+
1104
+ <details>
1105
+ <summary>Title and description guidelines</summary>
1106
+
1107
+ **Title:** Keep titles under 60 characters so they don't get truncated in search results. Put the most important keywords first. Use a consistent format like `Page Name | Site Name`.
1108
+
1109
+ **Description:** Keep descriptions between 120–160 characters. Summarize the page content clearly — this is the snippet shown below the title in search results. Each page should have a unique description that accurately reflects its content.
1110
+
1111
+ Always use `<Head>`, `<Head.Title>`, and `<Head.Meta>` from `spiceflow/react` instead of raw `<head>`, `<title>`, and `<meta>` tags. The `Head` components are type-safe, automatically deduplicated (page tags override layout tags with the same key), and correctly injected into the document head during SSR.
1112
+
1113
+ </details>
1114
+
1115
+ ```tsx
1116
+ .page('/', async () => {
1117
+ return (
1118
+ <div>
1119
+ <Head>
1120
+ <Head.Title>Spiceflow – Build Type-Safe APIs</Head.Title>
1121
+ <Head.Meta name="description" content="A fast, type-safe API and RSC framework for TypeScript." />
1122
+ </Head>
1123
+ <h1>Welcome</h1>
1124
+ </div>
1125
+ )
1126
+ })
1127
+ ```
1128
+
1129
+ If you want a consistent title prefix or suffix across all pages, create a wrapper component:
1130
+
1131
+ ```tsx
1132
+ function PageHead({ title, description }: { title: string; description: string }) {
1133
+ return (
1134
+ <Head>
1135
+ <Head.Title>{title} | My App</Head.Title>
1136
+ <Head.Meta name="description" content={description} />
1137
+ </Head>
1138
+ )
1139
+ }
1140
+
1141
+ // Then use it in any page
1142
+ .page('/about', async () => {
1143
+ return (
1144
+ <div>
1145
+ <PageHead title="About" description="Learn more about our team and mission." />
1146
+ <h1>About</h1>
1147
+ </div>
1148
+ )
1149
+ })
1150
+ ```
1151
+
1152
+ ### Query Params
1153
+
1154
+ Define a `query` schema on routes and pages that accept query parameters — even when all params are optional. Use the object notation for `.page()` and `.route()` so the query requirements are documented in the route definition and accessible with full type safety in the handler:
1155
+
1156
+ ```tsx
1157
+ import { Spiceflow } from 'spiceflow'
1158
+ import { z } from 'zod'
1159
+
1160
+ export const app = new Spiceflow()
1161
+ // Object notation gives you typed query access
1162
+ .page({
1163
+ path: '/products',
1164
+ query: z.object({
1165
+ category: z.string().optional(),
1166
+ sort: z.enum(['price', 'name', 'date']).optional(),
1167
+ page: z.coerce.number().optional(),
1168
+ }),
1169
+ handler: async ({ query }) => {
1170
+ // query.category is string | undefined — fully typed
1171
+ // query.sort is 'price' | 'name' | 'date' | undefined
1172
+ // query.page is number | undefined
1173
+ const products = await getProducts(query)
1174
+ return (
1175
+ <div>
1176
+ <h1>Products</h1>
1177
+ {products.map((p) => <p key={p.id}>{p.name}</p>)}
1178
+ </div>
1179
+ )
1162
1180
  },
1163
1181
  })
1182
+ ```
1183
+
1184
+ <details>
1185
+ <summary>Why always define a query schema</summary>
1186
+
1187
+ Without a query schema, `query` is `Record<string, string | undefined>` — you lose autocomplete, typos go unnoticed, and there's no documentation of what the page accepts.
1188
+
1189
+ Always define a `query` schema on routes and pages that accept query parameters. Use `href()` to build links to these pages — when a route has a query schema, `href` enforces the correct query keys at compile time. If you rename or remove a query param from the schema, every `href()` call that references it becomes a type error — no stale links.
1190
+
1191
+ </details>
1192
+
1193
+ **Use `href()` to build links to these pages.** When a route has a query schema, `href` enforces the correct query keys at compile time. If you rename or remove a query param from the schema, every `href()` call that references it becomes a type error — no stale links:
1194
+
1195
+ ```tsx
1196
+ 'use client'
1197
+ import { getRouter, Link } from 'spiceflow/react'
1198
+ import type { App } from '../main'
1199
+
1200
+ export function ProductFilters() {
1201
+ const router = getRouter<App>()
1202
+ return (
1203
+ <nav>
1204
+ {/* TypeScript validates these query keys against the schema */}
1205
+ <Link href={router.href('/products', { category: 'shoes', sort: 'price' })}>
1206
+ Shoes by Price
1207
+ </Link>
1208
+ <Link href={router.href('/products', { sort: 'date', page: 2 })}>
1209
+ Page 2, newest first
1210
+ </Link>
1211
+
1212
+ {/* @ts-expect-error — 'color' is not in the query schema */}
1213
+ <Link href={router.href('/products', { color: 'red' })}>Red</Link>
1214
+ </nav>
1215
+ )
1216
+ }
1217
+ ```
1218
+
1219
+ The same pattern works for API routes with `.route()`. Query params are automatically coerced from strings to match the schema type — you don't need `z.coerce.number()`, just use `z.number()` directly:
1220
+
1221
+ ```tsx
1222
+ export const app = new Spiceflow()
1164
1223
  .route({
1165
1224
  method: 'GET',
1166
- path: '/public',
1167
- handler() {
1168
- return { message: 'Public data' }
1225
+ path: '/api/search',
1226
+ query: z.object({
1227
+ q: z.string(),
1228
+ limit: z.number().optional(),
1229
+ offset: z.number().optional(),
1230
+ }),
1231
+ handler({ query }) {
1232
+ // query.q is string, query.limit is number | undefined
1233
+ return searchDatabase(query.q, query.limit, query.offset)
1169
1234
  },
1170
1235
  })
1236
+ ```
1237
+
1238
+ **Array query params** use repeated keys in the URL: `?tag=a&tag=b` (not comma-separated). Single values are automatically wrapped into arrays when the schema expects `z.array()`:
1239
+
1240
+ ```tsx
1241
+ // URL: /api/posts?tag=react or /api/posts?tag=react&tag=typescript
1242
+ export const app = new Spiceflow().route({
1243
+ method: 'GET',
1244
+ path: '/api/posts',
1245
+ query: z.object({
1246
+ tag: z.array(z.string()),
1247
+ limit: z.number().optional(),
1248
+ }),
1249
+ handler({ query }) {
1250
+ // query.tag is always string[], even with a single ?tag=react
1251
+ // query.limit is number | undefined, coerced from the string automatically
1252
+ return getPostsByTags(query.tag)
1253
+ },
1254
+ })
1255
+ ```
1256
+
1257
+ ### Client Components
1258
+
1259
+ Mark interactive components with `"use client"` at the top of the file. These are hydrated in the browser and can use hooks like `useState`.
1260
+
1261
+ ```tsx
1262
+ // src/app/counter.tsx
1263
+ 'use client'
1264
+
1265
+ import { useState } from 'react'
1266
+
1267
+ export function Counter() {
1268
+ const [count, setCount] = useState(0)
1269
+ return (
1270
+ <div>
1271
+ <p>Count: {count}</p>
1272
+ <button onClick={() => setCount(count + 1)}>+</button>
1273
+ </div>
1274
+ )
1275
+ }
1276
+ ```
1277
+
1278
+ ### Loaders
1279
+
1280
+ Loaders run on the server before page and layout handlers. They solve a common problem: when you need the same data in both server components and client components, or in both a layout and a page, without prop drilling or React context.
1281
+
1282
+ Loaders only run for requests that also match a `.page()` or `.layout()`. They are not standalone endpoints. If you want to serve content without rendering a page or layout, use `.get()`, `.route()`, or another API handler instead.
1283
+
1284
+ ```tsx
1285
+ export const app = new Spiceflow()
1286
+ // Auth loader for all routes — wildcard pattern matches everything
1287
+ .loader('/*', async ({ request }) => {
1288
+ const user = await getUser(request.headers.get('cookie'))
1289
+ if (!user) throw redirect('/login')
1290
+ return { user }
1291
+ })
1292
+ // Page-specific loader
1293
+ .loader('/dashboard', async () => {
1294
+ const stats = await getStats()
1295
+ return { stats }
1296
+ })
1297
+ .layout('/*', async ({ loaderData, children }) => {
1298
+ // loaderData.user is available here from the wildcard loader
1299
+ return (
1300
+ <html>
1301
+ <body>
1302
+ <nav>{loaderData.user.name}</nav>
1303
+ {children}
1304
+ </body>
1305
+ </html>
1306
+ )
1307
+ })
1308
+ .page('/dashboard', async ({ loaderData }) => {
1309
+ // Both loaders matched — data is merged by specificity
1310
+ // loaderData = { user: ..., stats: ... }
1311
+ return <Dashboard user={loaderData.user} stats={loaderData.stats} />
1312
+ })
1313
+ ```
1314
+
1315
+ When multiple loaders match a route (e.g. `/*` and `/dashboard` both match `/dashboard`), their return values are merged into a single flat object. More specific loaders override less specific ones on key conflicts.
1316
+
1317
+ **Serialization**: loader return values are serialized through the React RSC flight format, not JSON. You can return JSX (including server components and client component elements with their props), `Promise`, async iterators, `Map`, `Set`, `Date`, `BigInt`, typed arrays, and any client component reference — all deserialized faithfully on the client. This means a loader can return a fully rendered `<Sidebar user={user} />` element and another component can receive it as `loaderData.sidebar` and drop it into the tree.
1318
+
1319
+ **Reading loader data in client components** uses the `useLoaderData` hook from `spiceflow/react`:
1320
+
1321
+ ```tsx
1322
+ // src/app/sidebar.tsx
1323
+ 'use client'
1324
+
1325
+ import { useLoaderData } from 'spiceflow/react'
1326
+ import type { App } from '../main'
1327
+
1328
+ export function Sidebar() {
1329
+ // Type-safe: path narrows the return type to the loaders matching '/dashboard'
1330
+ const { user, stats } = useLoaderData<App>('/dashboard')
1331
+ return (
1332
+ <aside>
1333
+ {user.name} — {stats.totalViews} views
1334
+ </aside>
1335
+ )
1336
+ }
1337
+ ```
1338
+
1339
+ Loader data updates automatically on client-side navigation — when the user navigates to a new route, the server re-runs the matching loaders and the new data arrives atomically with the new page content via the RSC flight stream.
1340
+
1341
+ **Reading loader data imperatively** uses `getRouter<App>()`. This works in client code outside React components and during active server render. Call it inside component scope, event handlers, or helper functions tied to the current render flow instead of binding request-sensitive access at module scope:
1342
+
1343
+ ```tsx
1344
+ // src/app/editor-toolbar.tsx
1345
+ 'use client'
1346
+
1347
+ import { getRouter, useLoaderData } from 'spiceflow/react'
1348
+ import type { App } from '../main'
1349
+
1350
+ async function readCurrentDocument() {
1351
+ return getRouter<App>().getLoaderData('/editor/:id')
1352
+ }
1353
+
1354
+ export function EditorToolbar() {
1355
+ const router = getRouter<App>()
1356
+ const { document } = useLoaderData<App>('/editor/:id')
1357
+
1358
+ async function refresh() {
1359
+ const next = await readCurrentDocument()
1360
+ console.log(next.document.title)
1361
+ }
1362
+
1363
+ return <button onClick={refresh}>{document.title}</button>
1364
+ }
1365
+ ```
1366
+
1367
+ **Error handling**: if a loader throws a `redirect()` or `notFound()`, the entire request short-circuits — the page handler never runs. If a loader throws any other error, it renders through the nearest error boundary instead of showing a blank page.
1368
+
1369
+ ### Parallel Data Fetching
1370
+
1371
+ Spiceflow already parallelizes at the framework level — all matched loaders run concurrently, then layouts and the page render concurrently after loaders finish. Within a single handler, use `Promise.all` for independent fetches instead of sequential `await`s:
1372
+
1373
+ ```tsx
1374
+ .page('/dashboard', async () => {
1375
+ const [user, posts, analytics] = await Promise.all([
1376
+ getUser(),
1377
+ getPosts(),
1378
+ getStats(),
1379
+ ])
1380
+ return <Dashboard user={user} posts={posts} analytics={analytics} />
1381
+ })
1382
+ ```
1383
+
1384
+ ### Forms & Server Actions
1385
+
1386
+ Forms use React 19's `<form action>` with server functions marked `"use server"`. They work before JavaScript loads (progressive enhancement).
1387
+
1388
+ **Every server action call automatically re-renders the current page with fresh server data.** This applies to forms, client wrapper functions, and direct imported server action calls. The re-render happens via React reconciliation, so client component state is preserved. No manual `router.refresh()` needed after a server action.
1389
+
1390
+ Every submit button should show a loading state while its form action is in progress. Use `useFormStatus` from `react-dom` in your Button component to auto-detect pending forms — the button shows a spinner automatically when it's inside a `<form>` with a pending action:
1391
+
1392
+ Prefer file-level `"use server"` (a dedicated file like `src/actions.tsx`) over inline `"use server"` inside function bodies. Inline is fine for simple form actions defined directly in a server component page, but if you find yourself passing actions as props to client components, import them from a `"use server"` file instead — it keeps action logic centralized and reusable. The inline examples below are kept short for readability.
1393
+
1394
+ ```tsx
1395
+ // src/app/button.tsx
1396
+ 'use client'
1397
+ import { useFormStatus } from 'react-dom'
1398
+
1399
+ export function Button({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
1400
+ const { pending } = useFormStatus()
1401
+ const loading = props.type === 'submit' && pending
1402
+ return (
1403
+ <button disabled={loading} {...props}>
1404
+ {loading ? 'Loading...' : children}
1405
+ </button>
1406
+ )
1407
+ }
1408
+ ```
1409
+
1410
+ Then use it in forms — no manual loading state needed:
1411
+
1412
+ ```tsx
1413
+ import { redirect } from 'spiceflow'
1414
+ import { Button } from './app/button'
1415
+
1416
+ .page('/subscribe', async () => {
1417
+ async function subscribe(formData: FormData) {
1418
+ 'use server'
1419
+ const email = formData.get('email') as string
1420
+ await addSubscriber(email)
1421
+ throw redirect('/thank-you')
1422
+ }
1423
+ return (
1424
+ <form action={subscribe}>
1425
+ <input name="email" type="email" required />
1426
+ <Button type="submit">Subscribe</Button>
1427
+ </form>
1428
+ )
1429
+ })
1430
+ ```
1431
+
1432
+ Use `useActionState` to display return values from the action. The action receives the previous state as its first argument and `FormData` as the second:
1433
+
1434
+ ```tsx
1435
+ // src/actions.tsx
1436
+ 'use server'
1437
+
1438
+ export async function subscribe(prev: string, formData: FormData) {
1439
+ const email = formData.get('email') as string
1440
+ await addSubscriber(email)
1441
+ return `Subscribed ${email}!`
1442
+ }
1443
+ ```
1444
+
1445
+ ```tsx
1446
+ // src/app/newsletter.tsx
1447
+ 'use client'
1448
+ import { useActionState } from 'react'
1449
+ import { Button } from './button'
1450
+
1451
+ export function NewsletterForm({
1452
+ action,
1453
+ }: {
1454
+ action: (prev: string, formData: FormData) => Promise<string>
1455
+ }) {
1456
+ const [message, formAction] = useActionState(action, '')
1457
+ return (
1458
+ <form action={formAction}>
1459
+ <input name="email" type="email" required />
1460
+ <Button type="submit">Subscribe</Button>
1461
+ {message && <p>{message}</p>}
1462
+ </form>
1463
+ )
1464
+ }
1465
+ ```
1466
+
1467
+ ```tsx
1468
+ // In your server component page
1469
+ .page('/newsletter', async () => {
1470
+ async function subscribe(prev: string, formData: FormData) {
1471
+ 'use server'
1472
+ const email = formData.get('email') as string
1473
+ await addSubscriber(email)
1474
+ return `Subscribed ${email}!`
1475
+ }
1476
+ return <NewsletterForm action={subscribe} />
1477
+ })
1478
+ ```
1479
+
1480
+ Server actions called directly from client event handlers also trigger the same automatic re-render:
1481
+
1482
+ ```tsx
1483
+ // src/actions.ts
1484
+ 'use server'
1485
+
1486
+ export async function deletePost(id: string) {
1487
+ await db.posts.delete(id)
1488
+ }
1489
+ ```
1490
+
1491
+ ```tsx
1492
+ // src/app/delete-button.tsx
1493
+ 'use client'
1494
+
1495
+ import { deletePost } from '../actions'
1496
+
1497
+ export function DeleteButton({ id }: { id: string }) {
1498
+ return (
1499
+ <button
1500
+ onClick={async () => {
1501
+ await deletePost(id)
1502
+ // page re-renders automatically — no router.refresh() needed
1503
+ }}
1504
+ >
1505
+ Delete
1506
+ </button>
1507
+ )
1508
+ }
1509
+ ```
1510
+
1511
+ <details>
1512
+ <summary>Avoid deadlocks in client form actions</summary>
1513
+
1514
+ `router.refresh()` is fire-and-forget. Do not build awaitable navigation or refresh helpers and then use them inside a React client form action (`<form action={async () => { ... }}>`). React keeps that form action transition pending until the action returns, so awaiting the refresh or navigation commit from inside the action can deadlock the page.
1515
+
1516
+ </details>
1517
+
1518
+ ### Progress Bar
1519
+
1520
+ Render `<ProgressBar />` once in the root layout. For manual client-side async work, wrap the call in `ProgressBar.start()` / `ProgressBar.end()`:
1521
+
1522
+ ```tsx
1523
+ // src/main.tsx
1524
+ import { Spiceflow } from 'spiceflow'
1525
+ import { ProgressBar } from 'spiceflow/react'
1526
+ import { SaveButton } from './app/save-button'
1527
+
1528
+ export const app = new Spiceflow().layout('/*', async ({ children }) => {
1529
+ return (
1530
+ <html>
1531
+ <body>
1532
+ <ProgressBar />
1533
+ {children}
1534
+ <SaveButton />
1535
+ </body>
1536
+ </html>
1537
+ )
1538
+ })
1539
+
1540
+ // src/app/save-button.tsx
1541
+ 'use client'
1542
+
1543
+ import { ProgressBar } from 'spiceflow/react'
1544
+
1545
+ export function SaveButton() {
1546
+ return (
1547
+ <button
1548
+ onClick={async () => {
1549
+ ProgressBar.start()
1550
+ try {
1551
+ await fetch('/api/save', { method: 'POST' })
1552
+ } finally {
1553
+ ProgressBar.end()
1554
+ }
1555
+ }}
1556
+ >
1557
+ Save
1558
+ </button>
1559
+ )
1560
+ }
1561
+ ```
1562
+
1563
+ Manual calls share the same state as router navigation, so if a navigation and a client fetch overlap, the bar stays visible until both have finished.
1564
+
1565
+ <details>
1566
+ <summary>React export shape</summary>
1567
+
1568
+ Do not mix React component exports with non-React exports like `const`, `Context`, or plain helper functions in the same public module. That can break HMR / Fast Refresh because the module stops behaving like a pure component module.
1569
+
1570
+ If a component needs imperative helpers, attach them as static properties on the component instead of exporting separate helpers. For example, prefer `ProgressBar.start()` / `ProgressBar.end()` over standalone `startProgressBar()` or `endProgressBar()` exports.
1571
+
1572
+ </details>
1573
+
1574
+ If a server action throws, the error is caught by the nearest `ErrorBoundary`. The error message is preserved (sanitized to strip secrets) and displayed to the user in both development and production builds.
1575
+
1576
+ ### Error Handling
1577
+
1578
+ Use `ErrorBoundary` from `spiceflow/react` to catch errors from form actions. It provides `ErrorBoundary.ErrorMessage` and `ErrorBoundary.ResetButton` sub-components that read the error and reset function from context — so they work as standalone elements anywhere in the `fallback` tree.
1579
+
1580
+ Actions should **throw errors** instead of returning error strings. Return **objects** for rich success data instead of scalars:
1581
+
1582
+ ```tsx
1583
+ // src/actions.ts
1584
+ 'use server'
1585
+
1586
+ export async function createPost({ title }: { title: string }) {
1587
+ if (!title) throw new Error('Title is required')
1588
+ const post = await db.posts.create({ title })
1589
+ return { id: post.id }
1590
+ }
1591
+ ```
1592
+
1593
+ ```tsx
1594
+ // src/app/create-post.tsx
1595
+ 'use client'
1596
+
1597
+ import { ErrorBoundary } from 'spiceflow/react'
1598
+ import { createPost } from '../actions'
1599
+
1600
+ export function CreatePostForm() {
1601
+ return (
1602
+ <ErrorBoundary
1603
+ fallback={
1604
+ <div>
1605
+ <ErrorBoundary.ErrorMessage className="text-red-500" />
1606
+ <ErrorBoundary.ResetButton>Try again</ErrorBoundary.ResetButton>
1607
+ </div>
1608
+ }
1609
+ >
1610
+ <form
1611
+ action={async (formData: FormData) => {
1612
+ const title = formData.get('title') as string
1613
+ await createPost({ title })
1614
+ }}
1615
+ >
1616
+ <input name="title" required />
1617
+ <Button type="submit">Create</Button>
1618
+ </form>
1619
+ </ErrorBoundary>
1620
+ )
1621
+ }
1622
+ ```
1623
+
1624
+ `ErrorBoundary.ErrorMessage` renders a `<div>` and `ErrorBoundary.ResetButton` renders a `<button>` — both accept all their respective HTML element props via `...props` spread, so you can pass `className`, `style`, `data-testid`, etc.
1625
+
1626
+ When the form action throws, the `ErrorBoundary` catches the error, hides the form, and renders the `fallback` with the error message and a reset button. Clicking "Try again" restores the form. The error boundary also auto-resets when the user navigates to a different page.
1627
+
1628
+ For **direct action calls** (onClick handlers, not forms), use try/catch since the error doesn't propagate through React's rendering. Wrap in `startTransition` if you want pending state (`isPending`) and non-blocking behavior while the server data loads:
1629
+
1630
+ ```tsx
1631
+ import { useTransition } from 'react'
1632
+
1633
+ function DeleteButton({ id }: { id: string }) {
1634
+ const [isPending, startTransition] = useTransition()
1635
+ return (
1636
+ <button
1637
+ disabled={isPending}
1638
+ onClick={() => {
1639
+ startTransition(async () => {
1640
+ try {
1641
+ await deletePost({ id })
1642
+ } catch (e) {
1643
+ alert(e.message)
1644
+ }
1645
+ })
1646
+ }}
1647
+ >
1648
+ {isPending ? 'Deleting...' : 'Delete'}
1649
+ </button>
1650
+ )
1651
+ }
1652
+ ```
1653
+
1654
+ ### Redirecting After Actions
1655
+
1656
+ When a server action needs to navigate to a different page (e.g. after creating a resource), use `throw redirect()` inside the action instead of `router.push()` on the client. Since every server action triggers a page re-render, calling `router.push()` after the action would briefly flash the re-rendered current page before navigating away.
1657
+
1658
+ ```tsx
1659
+ // src/actions.ts
1660
+ 'use server'
1661
+
1662
+ import { redirect } from 'spiceflow'
1663
+
1664
+ export async function createProject({ name, orgId }: { name: string; orgId: string }) {
1665
+ const project = await db.projects.create({ name, orgId })
1666
+ throw redirect(`/orgs/${orgId}/projects/${project.id}`)
1667
+ }
1668
+ ```
1669
+
1670
+ ```tsx
1671
+ // src/app/create-project.tsx
1672
+ 'use client'
1673
+
1674
+ import { ErrorBoundary } from 'spiceflow/react'
1675
+ import { createProject } from '../actions'
1676
+
1677
+ export function CreateProjectForm({ orgId }: { orgId: string }) {
1678
+ return (
1679
+ <ErrorBoundary fallback={<ErrorBoundary.ErrorMessage />}>
1680
+ <form action={async (formData: FormData) => {
1681
+ const name = formData.get('name') as string
1682
+ await createProject({ name, orgId })
1683
+ // no router.push needed — the action redirects server-side
1684
+ }}>
1685
+ <input name="name" required />
1686
+ <button type="submit">Create</button>
1687
+ </form>
1688
+ </ErrorBoundary>
1689
+ )
1690
+ }
1691
+ ```
1692
+
1693
+ `router.push()`, `router.replace()`, `router.back()`, `router.forward()`, and `router.go()` are still the right choice for pure client-side navigation that doesn't involve a server action (e.g. tab switches, select dropdowns, back buttons). These APIs are all fire-and-forget — do not build awaitable wrappers around navigation commits and then call them inside a React client form action.
1694
+
1695
+ ### Router
1696
+
1697
+ Use `getRouter` with your app type for type-safe navigation, URL building, and imperative loader data access. It works in **both client and server components** — in server/RSC code it reads the current request's location from async context, and `router.href()` builds typed URLs the same way. `useLoaderData` and `useRouterState` are exported separately from `spiceflow/react`, and both accept the same optional app generic.
1698
+
1699
+ `getRouter()` returns a **stable singleton** — the same object reference every time. It's safe to call in component bodies, pass to hook dependency arrays, or store at module scope. The reference never changes between renders, so it won't trigger unnecessary re-renders or effect re-runs.
1700
+
1701
+ Use `href()` for links so route and query changes are caught by TypeScript.
1702
+
1703
+ ```tsx
1704
+ // src/app/nav.tsx
1705
+ 'use client'
1706
+
1707
+ import { getRouter, Link } from 'spiceflow/react'
1708
+ import type { App } from '../main'
1709
+
1710
+ export function Nav() {
1711
+ const router = getRouter<App>()
1712
+
1713
+ return (
1714
+ <nav>
1715
+ <Link href={router.href('/')}>Home</Link>
1716
+ <Link href={router.href('/about')}>About</Link>
1717
+ <Link href={router.href('/users/:id', { id: '1' })}>User 1</Link>
1718
+ <Link href={router.href('/search', { q: 'docs', page: 1 })}>Search Docs</Link>
1719
+ </nav>
1720
+ )
1721
+ }
1722
+ ```
1723
+
1724
+ <details>
1725
+ <summary>Using getRouter in mounted sub-apps</summary>
1726
+
1727
+ `getRouter<App>()` sees all routes registered on the root app, regardless of where you call it. Inside a sub-app mounted with `.use()`, it still sees the whole route table — not just the sub-app's own routes:
1728
+
1729
+ ```tsx
1730
+ // src/features/billing/page.tsx — a sub-app mounted into the main app
1731
+ import { Spiceflow } from 'spiceflow'
1732
+ import { getRouter, Link } from 'spiceflow/react'
1733
+ import type { App } from '../../main'
1734
+
1735
+ export const billingApp = new Spiceflow().page('/billing', async () => {
1736
+ // router is typed against the WHOLE app, not just billingApp
1737
+ const router = getRouter<App>()
1738
+ return (
1739
+ <div>
1740
+ <h1>Billing</h1>
1741
+ {/* Link to a route defined in a different sub-app */}
1742
+ <Link href={router.href('/users/:id', { id: '42' })}>Back to profile</Link>
1743
+ </div>
1744
+ )
1745
+ })
1746
+ ```
1747
+
1748
+ No need to thread `app` through props or imports — every call is still fully type-checked against the root app's route table.
1749
+
1750
+ </details>
1751
+
1752
+ Wildcard routes like `/orgs/:orgId/*` accept **template literals** with interpolated values. TypeScript template literal types ensure only strings matching a registered route pattern are accepted:
1753
+
1754
+ ```tsx
1755
+ // Pattern form — pass params as an object
1756
+ router.href('/orgs/:orgId/*', { orgId: 'acme', '*': 'projects' })
1757
+ // → "/orgs/acme/projects"
1758
+
1759
+ // Template literal form — params already in the string
1760
+ const orgId = 'acme'
1761
+ router.href(`/orgs/${orgId}/projects`)
1762
+ // → "/orgs/acme/projects"
1763
+
1764
+ // Works with any depth under the wildcard
1765
+ const projectId = 'p1'
1766
+ router.href(`/orgs/${orgId}/projects/${projectId}/settings`)
1767
+ // → "/orgs/acme/projects/p1/settings"
1768
+ ```
1769
+
1770
+ The pattern form gives the strongest type checking — param names, query keys, and route existence are all validated. The template literal form is checked against registered route prefixes, but once values are interpolated TypeScript no longer knows the original param names. Invalid prefixes like `/settings/foo` still error at compile time either way.
1771
+
1772
+ `getRouter()` works on the server too — use it in server components to build type-safe links without needing the `app` closure:
1773
+
1774
+ ```tsx
1775
+ // src/app/org-breadcrumb.tsx (server component — no "use client")
1776
+ import { getRouter, Link } from 'spiceflow/react'
1777
+ import type { App } from '../main'
1778
+
1779
+ export async function OrgBreadcrumb({ orgId }: { orgId: string }) {
1780
+ const router = getRouter<App>()
1781
+ return (
1782
+ <nav>
1783
+ <Link href={router.href('/')}>Home</Link>
1784
+ <span> / </span>
1785
+ <Link href={router.href(`/orgs/${orgId}/projects`)}>Projects</Link>
1786
+ </nav>
1787
+ )
1788
+ }
1789
+ ```
1790
+
1791
+ <details>
1792
+ <summary>Always use href() for links</summary>
1793
+
1794
+ Every `Link` href and every programmatic navigation path should go through `href()`. Raw string paths like `<Link href="/users/42">` bypass type checking — if the route is renamed from `/users/:id` to `/profiles/:id`, the raw string silently becomes a 404 while `href('/users/:id', { id: '42' })` immediately fails `tsc`. When a route path changes or gets removed, `tsc` catches every stale `href()` call at compile time.
1795
+
1796
+ This applies to both client and server code. `getRouter<App>()` returns the same typed router everywhere — `router.href()` works identically in server components, client components, and the app entry file.
1797
+
1798
+ </details>
1799
+
1800
+ ### Navigation & State
1801
+
1802
+ The `router` object from `getRouter` handles type-safe client-side navigation. `router.push`, `router.replace`, and `router.href` accept typed paths with autocomplete — params and query values are validated at compile time:
1803
+
1804
+ ```tsx
1805
+ // src/app/search-filters.tsx
1806
+ 'use client'
1807
+
1808
+ import { useRouterState } from 'spiceflow/react'
1809
+ import { getRouter } from 'spiceflow/react'
1810
+ import type { App } from '../main'
1811
+
1812
+ export function SearchFilters() {
1813
+ const router = getRouter<App>()
1814
+ const { pathname, searchParams } = useRouterState<App>()
1815
+
1816
+ const query = searchParams.get('q') ?? ''
1817
+ const page = Number(searchParams.get('page') ?? '1')
1818
+ const sort = searchParams.get('sort') ?? 'relevance'
1819
+
1820
+ function setPage(n: number) {
1821
+ router.push({
1822
+ search: '?' + new URLSearchParams({ q: query, page: String(n), sort }),
1823
+ })
1824
+ }
1825
+
1826
+ function setSort(newSort: string) {
1827
+ router.push({
1828
+ search: '?' + new URLSearchParams({ q: query, page: '1', sort: newSort }),
1829
+ })
1830
+ }
1831
+
1832
+ return (
1833
+ <div>
1834
+ <p>
1835
+ Showing results for "{query}" — page {page}, sorted by {sort}
1836
+ </p>
1837
+ <button onClick={() => setSort('date')}>Sort by Date</button>
1838
+ <button onClick={() => setPage(page + 1)}>Next Page</button>
1839
+ </div>
1840
+ )
1841
+ }
1842
+ ```
1843
+
1844
+ `useRouterState<App>()` subscribes to navigation changes and re-renders the component when the URL changes. It returns the current `pathname`, `search`, `hash`, and a parsed `searchParams` (a read-only `URLSearchParams`). If you omit `App`, the hook still works at runtime but skips route-type inference.
1845
+
1846
+ You can also navigate to a different pathname with search params, or use `router.replace` to update without adding a history entry:
1847
+
1848
+ ```tsx
1849
+ function Example() {
1850
+ const router = getRouter<App>()
1851
+
1852
+ // Navigate to a new path with search params
1853
+ router.push({
1854
+ pathname: '/search',
1855
+ search: '?' + new URLSearchParams({ q: 'spiceflow' }),
1856
+ })
1857
+
1858
+ // Replace current history entry (back button skips this)
1859
+ router.replace({
1860
+ search: '?' + new URLSearchParams({ tab: 'settings' }),
1861
+ })
1862
+
1863
+ // Or just use a plain string
1864
+ router.push('/search?q=spiceflow&page=1')
1865
+ }
1866
+ ```
1867
+
1868
+ <details>
1869
+ <summary>Navigation methods are fire-and-forget</summary>
1870
+
1871
+ `router.push()`, `router.replace()`, `router.back()`, `router.forward()`, and `router.go()` schedule navigation and return immediately. Do not wrap them in helpers that wait for the next navigation commit and then call those helpers from a React client form action — React keeps the form action transition pending until the action returns, so awaiting that same commit can deadlock the page.
1872
+
1873
+ </details>
1874
+
1875
+ ### Server Actions
1876
+
1877
+ Use `"use server"` to define functions that run on the server but can be called from client components (e.g. form actions).
1878
+
1879
+ ```tsx
1880
+ // src/app/actions.tsx
1881
+ 'use server'
1882
+
1883
+ import { getActionRequest } from 'spiceflow'
1171
1884
 
1172
- async function getUser(sessionKey: string) {
1173
- await new Promise((resolve) => setTimeout(resolve, 100))
1174
- return sessionKey === 'valid'
1175
- ? { id: '123', email: 'user@example.com' }
1176
- : null
1885
+ export async function submitForm(formData: FormData) {
1886
+ const { signal } = getActionRequest()
1887
+ const name = formData.get('name')
1888
+ // signal is aborted when the client disconnects or cancels —
1889
+ // pass it to any downstream work so it cancels automatically
1890
+ await saveToDatabase(name, { signal })
1177
1891
  }
1178
1892
  ```
1179
1893
 
1180
- ## Model Context Protocol (MCP)
1894
+ On the client, `getActionAbortController()` returns the `AbortController` for the most recent in-flight call to a server action, or `undefined` if nothing is in-flight. Call `.abort()` to cancel the fetch.
1181
1895
 
1182
- Spiceflow includes a Model Context Protocol (MCP) plugin that exposes your API routes as tools and resources that can be used by AI language models like Claude. The MCP plugin makes it easy to let AI assistants interact with your API endpoints in a controlled way.
1896
+ Server actions include CSRF protection the `Origin` header of POST requests is checked against the app's origin. This check is **disabled in development** (when running via `vite dev`) so tunnels and proxies don't cause issues. In production, when using a reverse proxy, tunnel, or custom domain that changes the origin, server actions return `403 Forbidden: origin mismatch`. Use `allowedActionOrigins` to allow additional origins:
1183
1897
 
1184
- When you mount the MCP plugin (default path is `/mcp`), it automatically:
1898
+ ```tsx
1899
+ const app = new Spiceflow({
1900
+ allowedActionOrigins: [
1901
+ 'https://my-app.example.com',
1902
+ /\.my-tunnel\.dev$/,
1903
+ ],
1904
+ })
1905
+ ```
1185
1906
 
1186
- - Exposes all your routes as callable tools with proper input validation
1187
- - Exposes GET routes without query/path parameters as `resources`
1188
- - Provides an SSE-based transport for real-time communication
1189
- - Handles serialization of requests and responses
1907
+ Each entry can be an exact origin string or a `RegExp` tested against the request's `Origin` header.
1190
1908
 
1191
- This makes it simple to let AI models like Claude discover and call your API endpoints programmatically.
1909
+ ### Streaming UI from Server Actions
1192
1910
 
1193
- ### Basic MCP Usage
1911
+ Server actions can return JSX directly — including via async generators that stream React elements to the client incrementally. The RSC flight protocol serializes each yielded element as it arrives, and the client deserializes them into real React elements you can render.
1194
1912
 
1195
- Here's an example:
1913
+ This is useful for AI chat interfaces where the model generates structured output with tool calls. Instead of streaming raw text, you stream rendered UI:
1196
1914
 
1197
1915
  ```tsx
1198
- // Import the MCP plugin and client
1199
- import { mcp } from 'spiceflow/mcp'
1200
- import { Client } from '@modelcontextprotocol/sdk/client/index.js'
1201
- import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
1202
- import { Spiceflow } from 'spiceflow'
1203
- import {
1204
- ListToolsResultSchema,
1205
- CallToolResultSchema,
1206
- ListResourcesResultSchema,
1207
- } from '@modelcontextprotocol/sdk/types.js'
1916
+ // src/app/actions.tsx
1917
+ 'use server'
1918
+
1919
+ import { getActionRequest } from 'spiceflow'
1920
+ import { WeatherCard } from './weather-card'
1921
+ import { StockChart } from './stock-chart'
1922
+
1923
+ export async function* chat(
1924
+ messages: { role: string; content: string }[],
1925
+ ): AsyncGenerator<React.ReactElement> {
1926
+ // Pass the request signal to downstream work so the LLM call
1927
+ // is cancelled when the client aborts (e.g. clicks "Stop")
1928
+ const { signal } = getActionRequest()
1929
+ const stream = await callLLM(messages, { signal })
1930
+
1931
+ for await (const event of stream) {
1932
+ if (event.type === 'text') {
1933
+ yield <p>{event.content}</p>
1934
+ }
1935
+ if (event.type === 'tool_call' && event.name === 'get_weather') {
1936
+ const weather = await fetchWeather(event.args.city)
1937
+ yield <WeatherCard city={event.args.city} weather={weather} />
1938
+ }
1939
+ if (event.type === 'tool_call' && event.name === 'get_stock') {
1940
+ const data = await fetchStock(event.args.symbol)
1941
+ yield <StockChart symbol={event.args.symbol} data={data} />
1942
+ }
1943
+ }
1944
+ }
1945
+ ```
1208
1946
 
1209
- // Create a new app with some example routes
1210
- const app = new Spiceflow()
1211
- // Mount the MCP plugin at /mcp (default path)
1212
- .use(mcp())
1213
- // These routes will be available as tools
1214
- .route({
1215
- method: 'GET',
1216
- path: '/hello',
1217
- handler() {
1218
- return 'Hello World'
1219
- },
1947
+ ```tsx
1948
+ // src/app/chat.tsx
1949
+ 'use client'
1950
+
1951
+ import { useState, useTransition, type ReactNode } from 'react'
1952
+ import { getActionAbortController } from 'spiceflow/react'
1953
+ import { chat } from './actions'
1954
+
1955
+ export function Chat() {
1956
+ const [parts, setParts] = useState<ReactNode[]>([])
1957
+ const [isPending, startTransition] = useTransition()
1958
+
1959
+ function send(formData: FormData) {
1960
+ const message = formData.get('message') as string
1961
+ setParts([])
1962
+ startTransition(async () => {
1963
+ const stream = await chat([{ role: 'user', content: message }])
1964
+ for await (const jsx of stream) {
1965
+ setParts((prev) => [...prev, jsx])
1966
+ }
1967
+ })
1968
+ }
1969
+
1970
+ return (
1971
+ <div>
1972
+ <div>{parts.map((part, i) => <div key={i}>{part}</div>)}</div>
1973
+ <form action={send}>
1974
+ <input name="message" placeholder="Ask something..." />
1975
+ <button type="submit" disabled={isPending}>Send</button>
1976
+ {isPending && (
1977
+ <button type="button" onClick={() => getActionAbortController(chat)?.abort()}>
1978
+ Stop
1979
+ </button>
1980
+ )}
1981
+ </form>
1982
+ </div>
1983
+ )
1984
+ }
1985
+ ```
1986
+
1987
+ Each yielded element — whether a text paragraph, a weather card, or a stock chart — arrives as a fully rendered React component. The client doesn't need to know how to render tool calls; it just accumulates whatever JSX the server sends.
1988
+
1989
+ ### Redirects and Not Found
1990
+
1991
+ Use `redirect()` and `response.status` inside `.page()`, `.layout()`, and server action handlers to control navigation and HTTP status codes:
1992
+
1993
+ ```tsx
1994
+ import { Spiceflow, redirect } from 'spiceflow'
1995
+
1996
+ export const app = new Spiceflow()
1997
+ .layout('/*', async ({ children, request }) => {
1998
+ // When no page matches, children is null — render a custom 404
1999
+ return (
2000
+ <AppLayout>
2001
+ {children ?? <NotFound />}
2002
+ </AppLayout>
2003
+ )
1220
2004
  })
1221
- .route({
1222
- method: 'GET',
1223
- path: '/users/:id',
1224
- handler({ params }) {
1225
- return { id: params.id }
1226
- },
2005
+ .page('/dashboard', async ({ request }) => {
2006
+ const user = await getUser(request)
2007
+ if (!user) {
2008
+ throw redirect('/login')
2009
+ }
2010
+ return <Dashboard user={user} />
1227
2011
  })
1228
- .route({
1229
- method: 'POST',
1230
- path: '/echo',
1231
- async handler({ request }) {
1232
- const body = await request.json()
1233
- return body
1234
- },
2012
+ .page('/posts/:id', async ({ params, response }) => {
2013
+ const post = await getPost(params.id)
2014
+ if (!post) {
2015
+ response.status = 404
2016
+ return <NotFound message={`Post ${params.id} not found`} />
2017
+ }
2018
+ return <Post post={post} />
2019
+ })
2020
+ // Layouts can throw redirect — useful for auth guards that protect
2021
+ // an entire section of your app
2022
+ .layout('/admin/*', async ({ children, request }) => {
2023
+ const user = await getUser(request)
2024
+ if (!user?.isAdmin) {
2025
+ throw redirect('/login')
2026
+ }
2027
+ return <AdminLayout>{children}</AdminLayout>
1235
2028
  })
1236
2029
 
1237
- // Start the server
1238
- app.listen(3000)
1239
-
1240
- // Example client usage:
1241
- const transport = new SSEClientTransport(new URL('http://localhost:3000/mcp'))
1242
-
1243
- const client = new Client(
1244
- { name: 'example-client', version: '1.0.0' },
1245
- { capabilities: {} },
1246
- )
1247
-
1248
- await client.connect(transport)
1249
-
1250
- // List available tools
1251
- const tools = await client.request(
1252
- { method: 'tools/list' },
1253
- ListToolsResultSchema,
1254
- )
1255
-
1256
- // Call a tool
1257
- const result = await client.request(
1258
- {
1259
- method: 'tools/call',
1260
- params: {
1261
- name: 'GET /hello',
1262
- arguments: {},
1263
- },
1264
- },
1265
- CallToolResultSchema,
1266
- )
1267
-
1268
- // List available resources (only GET /hello is exposed since it has no params)
1269
- const resources = await client.request(
1270
- { method: 'resources/list' },
1271
- ListResourcesResultSchema,
1272
- )
2030
+ export type App = typeof app
1273
2031
  ```
1274
2032
 
1275
- ### Adding MCP Tools to Existing Server
2033
+ `redirect()` accepts an optional second argument for custom status codes and headers:
1276
2034
 
1277
- If you already have an existing MCP server and want to add Spiceflow route tools to it, you can use the `addMcpTools` helper function:
2035
+ ```tsx
2036
+ // 301 permanent redirect
2037
+ throw redirect('/new-url', { status: 301 })
1278
2038
 
1279
- ```ts
1280
- import { addMcpTools } from 'spiceflow/mcp'
1281
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2039
+ // Redirect with custom headers
2040
+ throw redirect('/login', {
2041
+ headers: { 'set-cookie': 'session=; Max-Age=0' },
2042
+ })
2043
+ ```
1282
2044
 
1283
- import { Spiceflow } from 'spiceflow'
2045
+ <details>
2046
+ <summary>Response status, headers, and HTTP behavior</summary>
1284
2047
 
1285
- // Your existing MCP server
1286
- const existingServer = new Server(
1287
- { name: 'my-server', version: '1.0.0' },
1288
- { capabilities: { tools: {}, resources: {} } },
1289
- )
2048
+ **`response.status` and `response.headers`** every page and layout handler receives a mutable `response` object on the context. Set `response.status` to control the HTTP status code (defaults to 200). Set `response.headers` to add custom headers like `cache-control` or `set-cookie`.
1290
2049
 
1291
- // Your Spiceflow app
1292
- const app = new Spiceflow()
1293
- .use(mcp()) // Required for MCP configuration
1294
- .route({
1295
- method: 'GET',
1296
- path: '/hello',
1297
- handler() {
1298
- return 'Hello from Spiceflow!'
1299
- },
1300
- })
2050
+ **Correct HTTP status codes.** Unlike Next.js, where redirects always return a 200 status with client-side handling, Spiceflow returns the actual HTTP status code in the response — `307` for redirects (with a `Location` header) and whatever you set via `response.status` for pages. This works even when the throw happens after an `await`, because the SSR layer intercepts the error from the RSC stream before flushing the HTML response. Search engines see correct status codes, and `fetch()` calls with `redirect: "manual"` get the real `307` response.
1301
2051
 
1302
- // Add Spiceflow tools to your existing server
1303
- const mcpServer = await addMcpTools({
1304
- mcpServer: existingServer,
1305
- app,
1306
- ignorePaths: ['/mcp', '/sse'],
1307
- })
2052
+ **Client-side navigation.** When a user clicks a `<Link>` that navigates to a page throwing `redirect()`, the router performs the redirect client-side without a full page reload.
1308
2053
 
1309
- // Now your existing server has access to all Spiceflow routes as tools
1310
- ```
2054
+ </details>
1311
2055
 
1312
- ## Generating Fern docs and SDK
2056
+ ### Code Splitting
1313
2057
 
1314
- Spiceflow has native support for Fern docs and SDK generation using openapi plugin.
2058
+ Code splitting of client components is **automatic** — you don't need `React.lazy()` or dynamic `import()`. Each `"use client"` file becomes a separate chunk, and the browser only loads the chunks needed for the current page.
1315
2059
 
1316
- The openapi types also have additional types for `x-fern` extensions to help you customize your docs and SDK.
2060
+ <details>
2061
+ <summary>How it works</summary>
1317
2062
 
1318
- Here is an example script to help you generate an openapi.yml file that you can then use with Fern:
2063
+ When the RSC flight stream is sent to the browser, it contains references to client component chunks rather than the actual code. The browser resolves and loads only the chunks referenced on the current page. If route `/about` uses `<Map />` and route `/dashboard` uses `<Chart />`, visiting `/about` will never download the Chart component's JavaScript.
1319
2064
 
1320
- ```ts
1321
- import fs from 'fs'
1322
- import path from 'path'
1323
- import yaml from 'js-yaml'
1324
- import { Spiceflow } from 'spiceflow'
1325
- import { openapi } from 'spiceflow/openapi'
1326
- import { createSpiceflowClient } from 'spiceflow/client'
2065
+ </details>
1327
2066
 
1328
- const app = new Spiceflow().use(openapi({ path: '/openapi' })).route({
1329
- method: 'GET',
1330
- path: '/hello',
1331
- handler() {
1332
- return 'Hello World'
1333
- },
1334
- })
2067
+ <details>
2068
+ <summary>Barrel file pitfall</summary>
1335
2069
 
1336
- async function main() {
1337
- console.log('Creating Spiceflow client...')
1338
- const client = createSpiceflowClient(app)
2070
+ Avoid barrel files with `"use client"`. If you have a single file with `"use client"` that re-exports many components, all of them end up in one chunk — defeating code splitting. Instead, put `"use client"` in each individual component file:
1339
2071
 
1340
- console.log('Fetching OpenAPI spec...')
1341
- const { data: openapiJson, error } = await client.openapi.get()
1342
- if (error) {
1343
- console.error('Failed to fetch OpenAPI spec:', error)
1344
- throw error
1345
- }
2072
+ ```tsx
2073
+ // BAD one big chunk for everything
2074
+ // src/components/index.tsx
2075
+ 'use client'
2076
+ export { Chart } from './chart'
2077
+ export { Map } from './map'
2078
+ export { Table } from './table'
2079
+ ```
1346
2080
 
1347
- const outputPath = path.resolve('./openapi.yml')
1348
- console.log('Writing OpenAPI spec to', outputPath)
1349
- fs.writeFileSync(
1350
- outputPath,
1351
- yaml.dump(openapiJson, {
1352
- indent: 2,
1353
- lineWidth: -1,
1354
- }),
1355
- )
1356
- console.log('Successfully wrote OpenAPI spec')
2081
+ ```tsx
2082
+ // GOOD each component is its own chunk
2083
+ // src/components/chart.tsx
2084
+ 'use client'
2085
+ export function Chart() {
2086
+ /* ... */
1357
2087
  }
1358
2088
 
1359
- main().catch((e) => {
1360
- console.error('Failed to generate OpenAPI spec:', e)
1361
- process.exit(1)
1362
- })
1363
- ```
1364
-
1365
- Then follow Fern docs to generate the SDK and docs. You will need to create some Fern yml config files.
2089
+ // src/components/map.tsx
2090
+ ;('use client')
2091
+ export function Map() {
2092
+ /* ... */
2093
+ }
1366
2094
 
1367
- You can take a look at the [`scripts/example-app.ts`](spiceflow/scripts/example-app.ts) file for an example app that generates the docs and SDK.
2095
+ // Re-export barrel has no directive, just passes through
2096
+ // src/components/index.tsx
2097
+ export { Chart } from './chart'
2098
+ export { Map } from './map'
2099
+ ```
1368
2100
 
1369
- ## Passing state during handle, passing Cloudflare env bindings
2101
+ </details>
1370
2102
 
1371
- You can use bindings type safely using a .state method and then passing the state in the handle method in the second argument:
2103
+ ### Directory Paths
1372
2104
 
1373
- ```tsx
1374
- import { Spiceflow } from 'spiceflow'
1375
- import { z } from 'zod'
2105
+ > Only available when using the Vite plugin.
1376
2106
 
1377
- interface Env {
1378
- KV: KVNamespace
1379
- QUEUE: Queue
1380
- SECRET: string
1381
- }
2107
+ Server components sometimes need to read files from the filesystem at runtime — for example, reading images from `public/` to generate Open Graph images, or writing cached files to disk. Using `import.meta.dirname` breaks on platforms like Vercel where the function runs from a different directory than where you built.
1382
2108
 
1383
- const app = new Spiceflow()
1384
- .state('env', {} as Env)
1385
- .route({
1386
- method: 'GET',
1387
- path: '/kv/:key',
1388
- async handler({ params, state }) {
1389
- const value = await state.env!.KV.get(params.key)
1390
- return { key: params.key, value }
1391
- },
1392
- })
1393
- .route({
1394
- method: 'POST',
1395
- path: '/queue',
1396
- async handler({ request, state }) {
1397
- const body = await request.json()
1398
- await state.env!.QUEUE.send(body)
1399
- return { success: true, message: 'Added to queue' }
1400
- },
1401
- })
2109
+ `publicDir` and `distDir` resolve to the correct absolute paths in every environment:
1402
2110
 
1403
- export default {
1404
- fetch(request: Request, env: Env, ctx: ExecutionContext) {
1405
- // Pass the env bindings to the app
1406
- return app.handle(request, { state: { env } })
1407
- },
2111
+ ```tsx
2112
+ import { publicDir, distDir } from 'spiceflow'
2113
+ import { readFile, writeFile } from 'node:fs/promises'
2114
+ import path from 'node:path'
2115
+
2116
+ export async function generateOgImage(slug: string) {
2117
+ const template = await readFile(path.join(publicDir, 'og-template.png'))
2118
+ // ... generate image
2119
+ await writeFile(path.join(distDir, 'cache', `${slug}.png`), result)
1408
2120
  }
1409
2121
  ```
1410
2122
 
1411
- ## Fern SDK streaming support
2123
+ | | `publicDir` | `distDir` |
2124
+ |---|---|---|
2125
+ | **Dev** | `<cwd>/public` | `<cwd>` |
2126
+ | **Production** | `<outDir>/client` (where Vite copies public/ contents) | `<outDir>` |
1412
2127
 
1413
- When you use an async generator in your app, Spiceflow will automatically add the required `x-fern` extensions to the OpenAPI spec to support streaming.
2128
+ ### Remote Components & Federation
1414
2129
 
1415
- Here is what streaming looks like in the Fern generated SDK:
2130
+ Expose any Flight-serializable value from a route with `encodeFederationPayload(...)`, then either render the fetched `Response` with `RenderFederatedPayload` or decode it imperatively with `decodeFederationPayload(response)`. This works for SSR'd remote components, plain objects, or objects containing JSX. Async iterables are supported when they are fields on an object payload (for example `{ stream }`), so clients can `for await` the decoded field directly.
1416
2131
 
1417
- ```ts
1418
- import { ExampleSdkClient } from './sdk-typescript'
2132
+ ```tsx
2133
+ // remote app
2134
+ import { encodeFederationPayload } from 'spiceflow/federation'
1419
2135
 
1420
- const sdk = new ExampleSdkClient({
1421
- environment: 'http://localhost:3000',
2136
+ .get('/api/chart', async () => {
2137
+ return await encodeFederationPayload(<Chart dataSource="revenue" />)
1422
2138
  })
1423
2139
 
1424
- // Get stream data
1425
- const stream = await sdk.getStream()
1426
- for await (const data of stream) {
1427
- console.log('Stream data:', data)
1428
- }
2140
+ // host app
2141
+ import { Suspense } from 'react'
2142
+ import { RenderFederatedPayload } from 'spiceflow/react'
1429
2143
 
1430
- // Simple GET request
1431
- const response = await sdk.getUsers()
1432
- console.log('Users:', response)
2144
+ .page('/', async () => {
2145
+ const response = await fetch('https://remote.example.com/api/chart')
2146
+ return (
2147
+ <Suspense fallback={<div>Loading chart...</div>}>
2148
+ <RenderFederatedPayload response={response} />
2149
+ </Suspense>
2150
+ )
2151
+ })
1433
2152
  ```
1434
2153
 
1435
- ## Working with Cookies
1436
-
1437
- Spiceflow works with standard Request and Response objects, so you can use any cookie library like the `cookie` npm package to handle cookies:
2154
+ See [Federation docs](docs/federation.md) for full setup, imperative decoding with `decodeFederationPayload`, import map deduplication, and external ESM components.
1438
2155
 
1439
- ```ts
1440
- import { Spiceflow } from 'spiceflow'
1441
- import { parse, serialize } from 'cookie'
2156
+ ## Model Context Protocol (MCP)
1442
2157
 
1443
- const app = new Spiceflow()
1444
- .route({
1445
- method: 'GET',
1446
- path: '/set-cookie',
1447
- handler({ request }) {
1448
- // Read existing cookies from the request
1449
- const cookies = parse(request.headers.get('Cookie') || '')
1450
-
1451
- // Create response with a new cookie
1452
- const response = new Response(
1453
- JSON.stringify({
1454
- message: 'Cookie set!',
1455
- existingCookies: cookies,
1456
- }),
1457
- {
1458
- headers: {
1459
- 'Content-Type': 'application/json',
1460
- },
1461
- },
1462
- )
2158
+ Spiceflow includes an MCP plugin that exposes your API routes as tools and resources for AI language models. Mount it with `.use(mcp())` and all routes become callable tools with proper input validation. See [MCP docs](docs/mcp.md) for full setup, client examples, and integrating with existing MCP servers.
1463
2159
 
1464
- // Set a new cookie
1465
- response.headers.set(
1466
- 'Set-Cookie',
1467
- serialize('session', 'abc123', {
1468
- httpOnly: true,
1469
- secure: true,
1470
- sameSite: 'strict',
1471
- maxAge: 60 * 60 * 24 * 7, // 7 days
1472
- path: '/',
1473
- }),
1474
- )
2160
+ ## KV Page Caching
1475
2161
 
1476
- return response
1477
- },
1478
- })
1479
- .route({
1480
- method: 'GET',
1481
- path: '/get-cookie',
1482
- handler({ request }) {
1483
- // Parse cookies from the request
1484
- const cookies = parse(request.headers.get('Cookie') || '')
2162
+ Cache full-page HTML in Cloudflare KV with deployment-aware cache keys. See [Cloudflare docs](docs/cloudflare.md#kv-page-caching) for the full middleware example.
1485
2163
 
1486
- return {
1487
- sessionId: cookies.session || null,
1488
- allCookies: cookies,
1489
- }
1490
- },
1491
- })
1492
- .route({
1493
- method: 'POST',
1494
- path: '/clear-cookie',
1495
- handler({ request }) {
1496
- const response = new Response(
1497
- JSON.stringify({ message: 'Cookie cleared!' }),
1498
- {
1499
- headers: {
1500
- 'Content-Type': 'application/json',
1501
- },
1502
- },
1503
- )
2164
+ ## Cross-Deployment Safety
1504
2165
 
1505
- // Clear a cookie by setting it with an expired date
1506
- response.headers.set(
1507
- 'Set-Cookie',
1508
- serialize('session', '', {
1509
- httpOnly: true,
1510
- secure: true,
1511
- sameSite: 'strict',
1512
- expires: new Date(0),
1513
- path: '/',
1514
- }),
1515
- )
2166
+ Spiceflow works across deployments without forced page reloads or cookies. When you deploy a new version, users with stale browser tabs continue working — both client navigations and server actions execute normally against the new server, as long as referenced client components remain backward-compatible.
1516
2167
 
1517
- return response
1518
- },
1519
- })
2168
+ This works because RSC flight payloads contain **client reference IDs** (a hash of the file path), not chunk URLs. The old client resolves these IDs from its own baked-in manifest and loads its own chunks from CDN. No duplicate React instances, no hydration mismatches. See [Deployment Skew](docs/deployment-skew.md) for a deep dive.
1520
2169
 
1521
- app.listen(3000)
1522
- ```
2170
+ <details>
2171
+ <summary>Edge cases and encryption</summary>
1523
2172
 
1524
- You can also use cookies in middleware for authentication or session handling:
2173
+ Cross-deployment requests can fail in two cases:
1525
2174
 
1526
- ```ts
1527
- import { Spiceflow } from 'spiceflow'
1528
- import { parse, serialize } from 'cookie'
2175
+ - The new server renders JSX containing a brand-new `"use client"` component that didn't exist in the old build — the old client's references map won't have that ID.
2176
+ - A client component keeps the same file path but its props interface changes between deploys — the old client loads old component code that receives incompatible props from the new server.
1529
2177
 
1530
- const app = new Spiceflow()
1531
- .state('userId', null as string | null)
1532
- .use(async ({ request, state }, next) => {
1533
- // Parse cookies from incoming request
1534
- const cookies = parse(request.headers.get('Cookie') || '')
1535
-
1536
- // Extract user ID from session cookie
1537
- if (cookies.session) {
1538
- // In a real app, you'd verify the session token
1539
- state.userId = cookies.session
1540
- }
2178
+ If you use inline `"use server"` functions that capture variables (bound arguments), set the `RSC_ENCRYPTION_KEY` environment variable to a stable base64-encoded 32-byte key so encrypted closures survive across deployments.
1541
2179
 
1542
- const response = await next()
2180
+ </details>
1543
2181
 
1544
- // Optionally refresh the session cookie
1545
- if (state.userId && response) {
1546
- response.headers.set(
1547
- 'Set-Cookie',
1548
- serialize('session', state.userId, {
1549
- httpOnly: true,
1550
- secure: true,
1551
- sameSite: 'strict',
1552
- maxAge: 60 * 60 * 24, // 24 hours
1553
- path: '/',
1554
- }),
1555
- )
1556
- }
2182
+ <details>
2183
+ <summary>How the deployment ID is resolved per environment</summary>
1557
2184
 
1558
- return response
1559
- })
1560
- .route({
1561
- method: 'GET',
1562
- path: '/profile',
1563
- handler({ state }) {
1564
- if (!state.userId) {
1565
- return new Response('Unauthorized', { status: 401 })
1566
- }
2185
+ Each production build stamps a unique deployment ID (build timestamp) into the server bundle. It's available via `getDeploymentId()` for custom logic (analytics, logging, cache keys) but is not used for request blocking.
1567
2186
 
1568
- return { userId: state.userId, message: 'Welcome back!' }
1569
- },
1570
- })
1571
- ```
2187
+ The deployment ID uses the `#deployment-id` import map in `package.json` with environment-conditional resolution:
1572
2188
 
1573
- ## Background tasks with waitUntil
2189
+ - **`react-server`** imports from `virtual:spiceflow-deployment-id` (the build timestamp baked in by Vite)
2190
+ - **`default`** (browser, tests) — returns `''`
1574
2191
 
1575
- Spiceflow provides a `waitUntil` function in the handler context that allows you to schedule tasks in the background in a cross platform way. It will use the Cloudflare workers waitUntil if present. It's currently a no op in Node.js.
2192
+ In dev mode the RSC loader also returns `''`.
1576
2193
 
1577
- ### Basic Usage
2194
+ </details>
1578
2195
 
1579
- ```ts
1580
- import { Spiceflow } from 'spiceflow'
2196
+ ## Node.js Handlers
1581
2197
 
1582
- const app = new Spiceflow().route({
1583
- method: 'POST',
1584
- path: '/process',
1585
- async handler({ request, waitUntil }) {
1586
- const data = await request.json()
2198
+ In user-facing code, you should almost never convert a Node.js `req`/`res` pair into a standard `Request` yourself. Spiceflow already exposes the right adapter for each situation, so this conversion should stay inside Spiceflow rather than in app code.
1587
2199
 
1588
- // Schedule background task
1589
- waitUntil(
1590
- fetch('https://analytics.example.com/track', {
1591
- method: 'POST',
1592
- body: JSON.stringify({ event: 'data_processed', data }),
1593
- }),
1594
- )
2200
+ <details>
2201
+ <summary>Which adapter to use</summary>
1595
2202
 
1596
- // Return response immediately
1597
- return { success: true, id: Math.random().toString(36) }
1598
- },
1599
- })
1600
- ```
2203
+ - If you want to run your app on a port in Node.js or Bun, use `app.listen(3000)`. Spiceflow sets up the server adapter for you. Cloudflare Workers are the main exception because there is no port-based server to listen on there.
2204
+ - If you need to plug a Spiceflow app into a classic Node.js handler API that gives you `req` and `res` (for example a Next.js pages API route), use `app.handleForNode(req, res)`. The older `app.handleNode(req, res)` alias also exists, but `handleForNode` is the current API.
2205
+ - If you are already inside a modern WHATWG-style handler that gives you a standard `Request`, just delegate with `return app.handle(request)`.
1601
2206
 
1602
- ### Cloudflare Workers Integration
2207
+ If you find yourself writing manual request-conversion glue in app code, that is usually a sign that you should use one of these Spiceflow entrypoints instead.
1603
2208
 
1604
- In Cloudflare Workers, `waitUntil` is automatically detected from the global context:
2209
+ </details>
1605
2210
 
1606
2211
  ```ts
1607
2212
  import { Spiceflow } from 'spiceflow'
2213
+ import type { IncomingMessage, ServerResponse } from 'node:http'
1608
2214
 
1609
- const app = new Spiceflow().route({
1610
- method: 'POST',
1611
- path: '/webhook',
1612
- async handler({ request, waitUntil }) {
1613
- const payload = await request.json()
1614
-
1615
- // Process webhook data in background
1616
- waitUntil(
1617
- processWebhookData(payload)
1618
- .then(() => console.log('Webhook processed'))
1619
- .catch((err) => console.error('Webhook processing failed:', err)),
1620
- )
1621
-
1622
- // Respond immediately to webhook sender
1623
- return new Response('OK', { status: 200 })
1624
- },
2215
+ export const app = new Spiceflow().get('/hello', () => {
2216
+ return { hello: 'world' }
1625
2217
  })
1626
2218
 
1627
- async function processWebhookData(payload: any) {
1628
- // Simulate time-consuming processing
1629
- await new Promise((resolve) => setTimeout(resolve, 1000))
1630
- // Save to database, send notifications, etc.
2219
+ // Run directly on Node.js or Bun
2220
+ app.listen(3000)
2221
+
2222
+ // Use inside a classic Node.js req/res handler
2223
+ export async function nodeHandler(req: IncomingMessage, res: ServerResponse) {
2224
+ await app.handleForNode(req, res)
1631
2225
  }
1632
2226
 
2227
+ // Use inside a standard Request handler
1633
2228
  export default {
1634
- fetch(request: Request, env: any, ctx: ExecutionContext) {
1635
- return app.handle(request, { state: { env } })
2229
+ fetch(request: Request) {
2230
+ return app.handle(request)
1636
2231
  },
1637
2232
  }
1638
2233
  ```
1639
2234
 
1640
- ## Next.js pages router integration
2235
+ ## Next.js Integration
1641
2236
 
1642
2237
  ```ts
1643
2238
  // pages/api/[...path].ts
@@ -1659,74 +2254,110 @@ export const config = {
1659
2254
  }
1660
2255
  ```
1661
2256
 
1662
- ### Custom waitUntil Function
2257
+ ## Docker Deployment
2258
+
2259
+ The build output is self-contained — `dist/` includes all traced runtime dependencies, so you can copy it directly into a Docker image without installing packages at deploy time. See [Docker docs](docs/docker.md) for Dockerfile examples and cross-platform native module handling.
2260
+
2261
+ ## Route Chaining
1663
2262
 
1664
- You can also provide your own `waitUntil` implementation:
2263
+ To preserve full type safety on the fetch client, routes must be chained in a single expression. Declaring the app separately and adding routes later loses the inferred types.
2264
+
2265
+ <details>
2266
+ <summary>Why chaining matters</summary>
2267
+
2268
+ When you declare routes separately, TypeScript can't infer the combined route types across multiple statements. The fetch client needs the full chain to infer path params, query params, body types, and response types.
1665
2269
 
1666
2270
  ```ts
2271
+ // This is an example of what NOT to do when using Spiceflow
2272
+
1667
2273
  import { Spiceflow } from 'spiceflow'
1668
2274
 
1669
- const app = new Spiceflow({
1670
- waitUntil: (promise) => {
1671
- // Custom implementation for non-Cloudflare environments
1672
- promise.catch((err) => console.error('Background task failed:', err))
1673
- },
1674
- }).route({
1675
- method: 'GET',
1676
- path: '/analytics',
1677
- async handler({ waitUntil }) {
1678
- // Schedule analytics tracking
1679
- waitUntil(trackPageView('/analytics'))
2275
+ // DO NOT declare the app separately and add routes later
2276
+ export const app = new Spiceflow()
1680
2277
 
1681
- return { message: 'Analytics page loaded' }
1682
- },
2278
+ // Do NOT do this! Defining routes separately will lose type safety
2279
+ app.get('/hello', () => {
2280
+ return 'Hello, World!'
2281
+ })
2282
+ // Do NOT do this! Adding routes separately like this will lose type safety
2283
+ app.post('/echo', async ({ request }) => {
2284
+ const body = await request.json()
2285
+ return body
1683
2286
  })
1684
-
1685
- async function trackPageView(path: string) {
1686
- // Track page view in analytics system
1687
- console.log(`Page view tracked: ${path}`)
1688
- }
1689
2287
  ```
1690
2288
 
1691
- **Note:** In non-Cloudflare environments, if no custom `waitUntil` function is provided, the default implementation is a no-op function that doesn't wait for the promises to complete.
2289
+ </details>
1692
2290
 
1693
- ## Graceful Shutdown
2291
+ ## Class Instances
2292
+
2293
+ If you need to store a Spiceflow router as a property in a class instance, use the `AnySpiceflow` type.
1694
2294
 
1695
- The `preventProcessExitIfBusy` middleware prevents platforms like Fly.io from killing your app while processing long requests (e.g., AI payloads). Fly.io can wait up to 5 minutes for graceful shutdown.
2295
+ <details>
2296
+ <summary>Avoid <code>this</code> in route handlers</summary>
2297
+
2298
+ Do not use `this` inside route handlers to reference the parent class. The `this` context inside handlers always refers to the Spiceflow instance, not your class instance. Instead, capture the parent class reference in a variable outside the handlers.
2299
+
2300
+ </details>
1696
2301
 
1697
2302
  ```ts
1698
- import { Spiceflow, preventProcessExitIfBusy } from 'spiceflow'
2303
+ import { Spiceflow, AnySpiceflow } from 'spiceflow'
1699
2304
 
1700
- const app = new Spiceflow()
1701
- .use(
1702
- preventProcessExitIfBusy({
1703
- maxWaitSeconds: 300, // 5 minutes max wait (default: 300)
1704
- checkIntervalMs: 250, // Check interval (default: 250ms)
1705
- }),
1706
- )
1707
- .route({
1708
- method: 'POST',
1709
- path: '/ai/generate',
1710
- async handler({ request }) {
1711
- const prompt = await request.json()
1712
- // Long-running AI generation
1713
- const result = await generateAIResponse(prompt)
1714
- return result
1715
- },
1716
- })
2305
+ export class ChatDurableObject {
2306
+ private router: AnySpiceflow
2307
+ private state: DurableObjectState
1717
2308
 
1718
- app.listen(3000)
2309
+ constructor(state: DurableObjectState, env: Env) {
2310
+ this.state = state
2311
+ const self = this // Capture parent class reference - IMPORTANT!
2312
+
2313
+ this.router = new Spiceflow()
2314
+ .route({
2315
+ method: 'GET',
2316
+ path: '/messages',
2317
+ async handler() {
2318
+ // Use 'self' instead of 'this' to access parent class
2319
+ // this.state would NOT work here - 'this' refers to Spiceflow instance
2320
+ const messages = (await self.state.storage.get('messages')) || []
2321
+ return { messages }
2322
+ },
2323
+ })
2324
+ .route({
2325
+ method: 'POST',
2326
+ path: '/messages',
2327
+ async handler({ request }) {
2328
+ const { message } = await request.json()
2329
+ // Use 'self' to access parent class properties
2330
+ const messages = (await self.state.storage.get('messages')) || []
2331
+ messages.push({ id: Date.now(), text: message })
2332
+ await self.state.storage.put('messages', messages)
2333
+ return { success: true }
2334
+ },
2335
+ })
2336
+ }
2337
+
2338
+ fetch(request: Request) {
2339
+ return this.router.handle(request)
2340
+ }
2341
+ }
1719
2342
  ```
1720
2343
 
1721
- When receiving SIGTERM during deployment, the middleware waits for all active requests to complete before exiting. Perfect for AI workloads that may take minutes to process.
2344
+ ## Comparisons
2345
+
2346
+ #### Elysia
2347
+
2348
+ This project was born as a fork of Elysia with several changes:
1722
2349
 
1723
- ### When using `createSpiceflowClient` and getting typescript error `The inferred type of 'pluginApiClient' cannot be named without a reference to '...'. This is likely not portable. A type annotation is necessary. (ts 2742)`
2350
+ - Use Zod instead of Typebox
2351
+ - Do not compile user code with `aot` and `eval`, Elysia is very difficult to contribue to because the app is generated by compiling the user routes with `new Function()`, which also causes [several bugs](https://github.com/elysiajs/elysia/pull/773)
2352
+ - Better async generator support by using SSE
1724
2353
 
1725
- You can resolve this issue by adding an explicing type for the client:
2354
+ #### Hono
1726
2355
 
1727
- ```ts
1728
- export const client: SpiceflowClient.Create<App> = createSpiceflowClient<App>(
1729
- PUBLIC_URL,
1730
- {},
1731
- )
1732
- ```
2356
+ This project shares many inspirations with Hono with many differences
2357
+
2358
+ - First class OpenAPI support, you don't need to change anything to produce an OpenAPI spec, just add the `openapi` plugin to automaitcally export your openapi schema on `/openapi`
2359
+ - Much simpler framework, everything is done with native `Request` and `Response` objects instead of framework specific utilities
2360
+ - Support for async generators
2361
+ - Adding schemas to your routes is easier and does not require using `validator` functions, which slow down TypeScript inference
2362
+ - The generated RPC client has much faster type inference, intellisense in VSCode appears in milliseconds instead of seconds
2363
+ - Spiceflow uses whatwg Request and Response instead of custom utilities like `c.text` and `c.req`