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.
- package/README.md +1839 -1208
- package/dist/_node-server.d.ts +7 -3
- package/dist/_node-server.d.ts.map +1 -1
- package/dist/_node-server.js +122 -52
- package/dist/_node-server.js.map +1 -1
- package/dist/_node-server.test.d.ts +2 -0
- package/dist/_node-server.test.d.ts.map +1 -0
- package/dist/_node-server.test.js +48 -0
- package/dist/_node-server.test.js.map +1 -0
- package/dist/action-context.d.ts +24 -0
- package/dist/action-context.d.ts.map +1 -0
- package/dist/action-context.js +33 -0
- package/dist/action-context.js.map +1 -0
- package/dist/any-spiceflow-types.test.d.ts +2 -0
- package/dist/any-spiceflow-types.test.d.ts.map +1 -0
- package/dist/any-spiceflow-types.test.js +202 -0
- package/dist/any-spiceflow-types.test.js.map +1 -0
- package/dist/base-path.d.ts +2 -0
- package/dist/base-path.d.ts.map +1 -0
- package/dist/base-path.js +22 -0
- package/dist/base-path.js.map +1 -0
- package/dist/client/fetch.d.ts +17 -5
- package/dist/client/fetch.d.ts.map +1 -1
- package/dist/client/fetch.js.map +1 -1
- package/dist/client/index.d.ts +12 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +13 -2
- package/dist/client/index.js.map +1 -1
- package/dist/client/shared.d.ts +6 -1
- package/dist/client/shared.d.ts.map +1 -1
- package/dist/client/shared.js +29 -3
- package/dist/client/shared.js.map +1 -1
- package/dist/client/types.d.ts +4 -3
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client.test.js +3 -7
- package/dist/client.test.js.map +1 -1
- package/dist/context.d.ts +22 -8
- package/dist/context.d.ts.map +1 -1
- package/dist/copy-anything.d.ts +6 -0
- package/dist/copy-anything.d.ts.map +1 -0
- package/dist/copy-anything.js +52 -0
- package/dist/copy-anything.js.map +1 -0
- package/dist/cors.d.ts +1 -1
- package/dist/cors.d.ts.map +1 -1
- package/dist/cors.js +16 -7
- package/dist/cors.js.map +1 -1
- package/dist/cors.test.js +11 -6
- package/dist/cors.test.js.map +1 -1
- package/dist/deployment-integration.test.d.ts +2 -0
- package/dist/deployment-integration.test.d.ts.map +1 -0
- package/dist/deployment-integration.test.js +30 -0
- package/dist/deployment-integration.test.js.map +1 -0
- package/dist/error.d.ts +16 -0
- package/dist/error.d.ts.map +1 -1
- package/dist/error.js +9 -0
- package/dist/error.js.map +1 -1
- package/dist/federation/shared/react-dom-client.d.ts +2 -0
- package/dist/federation/shared/react-dom-client.d.ts.map +1 -0
- package/dist/federation/shared/react-dom-client.js +2 -0
- package/dist/federation/shared/react-dom-client.js.map +1 -0
- package/dist/federation/shared/react-dom.d.ts +2 -0
- package/dist/federation/shared/react-dom.d.ts.map +1 -0
- package/dist/federation/shared/react-dom.js +2 -0
- package/dist/federation/shared/react-dom.js.map +1 -0
- package/dist/federation/shared/react-jsx-runtime.d.ts +2 -0
- package/dist/federation/shared/react-jsx-runtime.d.ts.map +1 -0
- package/dist/federation/shared/react-jsx-runtime.js +2 -0
- package/dist/federation/shared/react-jsx-runtime.js.map +1 -0
- package/dist/federation/shared/react.d.ts +2 -0
- package/dist/federation/shared/react.d.ts.map +1 -0
- package/dist/federation/shared/react.js +2 -0
- package/dist/federation/shared/react.js.map +1 -0
- package/dist/federation/shared/spiceflow-react.d.ts +2 -0
- package/dist/federation/shared/spiceflow-react.d.ts.map +1 -0
- package/dist/federation/shared/spiceflow-react.js +2 -0
- package/dist/federation/shared/spiceflow-react.js.map +1 -0
- package/dist/federation.default.d.ts +5 -0
- package/dist/federation.default.d.ts.map +1 -0
- package/dist/federation.default.js +11 -0
- package/dist/federation.default.js.map +1 -0
- package/dist/federation.rsc.d.ts +18 -0
- package/dist/federation.rsc.d.ts.map +1 -0
- package/dist/federation.rsc.js +225 -0
- package/dist/federation.rsc.js.map +1 -0
- package/dist/federation.rsc.test.d.ts +2 -0
- package/dist/federation.rsc.test.d.ts.map +1 -0
- package/dist/federation.rsc.test.js +52 -0
- package/dist/federation.rsc.test.js.map +1 -0
- package/dist/fetch-client.test.js +79 -6
- package/dist/fetch-client.test.js.map +1 -1
- package/dist/get-available-port.d.ts +2 -0
- package/dist/get-available-port.d.ts.map +1 -0
- package/dist/get-available-port.js +49 -0
- package/dist/get-available-port.js.map +1 -0
- package/dist/index.d.ts +17 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -4
- package/dist/index.js.map +1 -1
- package/dist/instrumentation.d.ts +40 -0
- package/dist/instrumentation.d.ts.map +1 -0
- package/dist/instrumentation.js +96 -0
- package/dist/instrumentation.js.map +1 -0
- package/dist/instrumentation.test.d.ts +2 -0
- package/dist/instrumentation.test.d.ts.map +1 -0
- package/dist/instrumentation.test.js +453 -0
- package/dist/instrumentation.test.js.map +1 -0
- package/dist/mcp-client-transport.d.ts.map +1 -1
- package/dist/mcp-client-transport.js +9 -4
- package/dist/mcp-client-transport.js.map +1 -1
- package/dist/mcp-transport.js +1 -1
- package/dist/mcp-transport.js.map +1 -1
- package/dist/mcp.d.ts +8 -61
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +9 -9
- package/dist/mcp.js.map +1 -1
- package/dist/mcp.test.d.ts +2 -0
- package/dist/mcp.test.d.ts.map +1 -0
- package/dist/mcp.test.js +323 -0
- package/dist/mcp.test.js.map +1 -0
- package/dist/middleware.test.js +203 -6
- package/dist/middleware.test.js.map +1 -1
- package/dist/openapi-docs-examples.test.d.ts +2 -0
- package/dist/openapi-docs-examples.test.d.ts.map +1 -0
- package/dist/openapi-docs-examples.test.js +506 -0
- package/dist/openapi-docs-examples.test.js.map +1 -0
- package/dist/openapi-to-mcp.d.ts.map +1 -1
- package/dist/openapi-to-mcp.js +53 -17
- package/dist/openapi-to-mcp.js.map +1 -1
- package/dist/openapi.d.ts +5 -25
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +14 -10
- package/dist/openapi.js.map +1 -1
- package/dist/openapi.test.js +22 -7
- package/dist/openapi.test.js.map +1 -1
- package/dist/prevent-process-exit-if-busy.d.ts.map +1 -1
- package/dist/prevent-process-exit-if-busy.js +1 -9
- package/dist/prevent-process-exit-if-busy.js.map +1 -1
- package/dist/query-coerce.d.ts +10 -0
- package/dist/query-coerce.d.ts.map +1 -0
- package/dist/query-coerce.js +138 -0
- package/dist/query-coerce.js.map +1 -0
- package/dist/react/action-abort.d.ts +17 -0
- package/dist/react/action-abort.d.ts.map +1 -0
- package/dist/react/action-abort.js +31 -0
- package/dist/react/action-abort.js.map +1 -0
- package/dist/react/components.d.ts +48 -0
- package/dist/react/components.d.ts.map +1 -0
- package/dist/react/components.js +192 -0
- package/dist/react/components.js.map +1 -0
- package/dist/react/context.d.ts +9 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +33 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/deployment-id.d.ts +2 -0
- package/dist/react/deployment-id.d.ts.map +1 -0
- package/dist/react/deployment-id.js +7 -0
- package/dist/react/deployment-id.js.map +1 -0
- package/dist/react/deployment-id.rsc.d.ts +2 -0
- package/dist/react/deployment-id.rsc.d.ts.map +1 -0
- package/dist/react/deployment-id.rsc.js +22 -0
- package/dist/react/deployment-id.rsc.js.map +1 -0
- package/dist/react/deployment.d.ts +10 -0
- package/dist/react/deployment.d.ts.map +1 -0
- package/dist/react/deployment.js +49 -0
- package/dist/react/deployment.js.map +1 -0
- package/dist/react/deployment.test.d.ts +2 -0
- package/dist/react/deployment.test.d.ts.map +1 -0
- package/dist/react/deployment.test.js +32 -0
- package/dist/react/deployment.test.js.map +1 -0
- package/dist/react/document-title.d.ts +4 -0
- package/dist/react/document-title.d.ts.map +1 -0
- package/dist/react/document-title.js +12 -0
- package/dist/react/document-title.js.map +1 -0
- package/dist/react/entry.client.d.ts +2 -0
- package/dist/react/entry.client.d.ts.map +1 -0
- package/dist/react/entry.client.js +376 -0
- package/dist/react/entry.client.js.map +1 -0
- package/dist/react/entry.rsc.d.ts +5 -0
- package/dist/react/entry.rsc.d.ts.map +1 -0
- package/dist/react/entry.rsc.js +35 -0
- package/dist/react/entry.rsc.js.map +1 -0
- package/dist/react/entry.ssr.d.ts +25 -0
- package/dist/react/entry.ssr.d.ts.map +1 -0
- package/dist/react/entry.ssr.js +258 -0
- package/dist/react/entry.ssr.js.map +1 -0
- package/dist/react/error-boundary.d.ts +12 -0
- package/dist/react/error-boundary.d.ts.map +1 -0
- package/dist/react/error-boundary.js +91 -0
- package/dist/react/error-boundary.js.map +1 -0
- package/dist/react/errors.d.ts +18 -0
- package/dist/react/errors.d.ts.map +1 -0
- package/dist/react/errors.js +85 -0
- package/dist/react/errors.js.map +1 -0
- package/dist/react/esm-island.d.ts +5 -0
- package/dist/react/esm-island.d.ts.map +1 -0
- package/dist/react/esm-island.js +64 -0
- package/dist/react/esm-island.js.map +1 -0
- package/dist/react/federated-payload.d.ts +47 -0
- package/dist/react/federated-payload.d.ts.map +1 -0
- package/dist/react/federated-payload.js +372 -0
- package/dist/react/federated-payload.js.map +1 -0
- package/dist/react/federated-payload.test.d.ts +2 -0
- package/dist/react/federated-payload.test.d.ts.map +1 -0
- package/dist/react/federated-payload.test.js +103 -0
- package/dist/react/federated-payload.test.js.map +1 -0
- package/dist/react/fetch.d.ts +6 -0
- package/dist/react/fetch.d.ts.map +1 -0
- package/dist/react/fetch.js +60 -0
- package/dist/react/fetch.js.map +1 -0
- package/dist/react/flight-data-context.d.ts +4 -0
- package/dist/react/flight-data-context.d.ts.map +1 -0
- package/dist/react/flight-data-context.js +3 -0
- package/dist/react/flight-data-context.js.map +1 -0
- package/dist/react/flight-data-context.rsc.d.ts +4 -0
- package/dist/react/flight-data-context.rsc.d.ts.map +1 -0
- package/dist/react/flight-data-context.rsc.js +2 -0
- package/dist/react/flight-data-context.rsc.js.map +1 -0
- package/dist/react/format-server-error.d.ts +2 -0
- package/dist/react/format-server-error.d.ts.map +1 -0
- package/dist/react/format-server-error.js +51 -0
- package/dist/react/format-server-error.js.map +1 -0
- package/dist/react/format-server-error.test.d.ts +2 -0
- package/dist/react/format-server-error.test.d.ts.map +1 -0
- package/dist/react/format-server-error.test.js +65 -0
- package/dist/react/format-server-error.test.js.map +1 -0
- package/dist/react/handle-ssr.d.ts +2 -0
- package/dist/react/handle-ssr.d.ts.map +1 -0
- package/dist/react/handle-ssr.js +6 -0
- package/dist/react/handle-ssr.js.map +1 -0
- package/dist/react/handle-ssr.rsc.d.ts +2 -0
- package/dist/react/handle-ssr.rsc.d.ts.map +1 -0
- package/dist/react/handle-ssr.rsc.js +14 -0
- package/dist/react/handle-ssr.rsc.js.map +1 -0
- package/dist/react/head-processing.d.ts +6 -0
- package/dist/react/head-processing.d.ts.map +1 -0
- package/dist/react/head-processing.js +99 -0
- package/dist/react/head-processing.js.map +1 -0
- package/dist/react/head-tags.d.ts +31 -0
- package/dist/react/head-tags.d.ts.map +1 -0
- package/dist/react/head-tags.js +2 -0
- package/dist/react/head-tags.js.map +1 -0
- package/dist/react/head.d.ts +29 -0
- package/dist/react/head.d.ts.map +1 -0
- package/dist/react/head.js +99 -0
- package/dist/react/head.js.map +1 -0
- package/dist/react/head.test.d.ts +2 -0
- package/dist/react/head.test.d.ts.map +1 -0
- package/dist/react/head.test.js +18 -0
- package/dist/react/head.test.js.map +1 -0
- package/dist/react/index.d.ts +14 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +12 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/index.rsc.d.ts +13 -0
- package/dist/react/index.rsc.d.ts.map +1 -0
- package/dist/react/index.rsc.js +10 -0
- package/dist/react/index.rsc.js.map +1 -0
- package/dist/react/link.d.ts +5 -0
- package/dist/react/link.d.ts.map +1 -0
- package/dist/react/link.js +45 -0
- package/dist/react/link.js.map +1 -0
- package/dist/react/loader-utils.d.ts +2 -0
- package/dist/react/loader-utils.d.ts.map +1 -0
- package/dist/react/loader-utils.js +34 -0
- package/dist/react/loader-utils.js.map +1 -0
- package/dist/react/prerender.d.ts +15 -0
- package/dist/react/prerender.d.ts.map +1 -0
- package/dist/react/prerender.js +149 -0
- package/dist/react/prerender.js.map +1 -0
- package/dist/react/progress.d.ts +35 -0
- package/dist/react/progress.d.ts.map +1 -0
- package/dist/react/progress.js +191 -0
- package/dist/react/progress.js.map +1 -0
- package/dist/react/progress.test.d.ts +2 -0
- package/dist/react/progress.test.d.ts.map +1 -0
- package/dist/react/progress.test.js +92 -0
- package/dist/react/progress.test.js.map +1 -0
- package/dist/react/react-cache.test.d.ts +2 -0
- package/dist/react/react-cache.test.d.ts.map +1 -0
- package/dist/react/react-cache.test.js +40 -0
- package/dist/react/react-cache.test.js.map +1 -0
- package/dist/react/remote-island.d.ts +6 -0
- package/dist/react/remote-island.d.ts.map +1 -0
- package/dist/react/remote-island.js +216 -0
- package/dist/react/remote-island.js.map +1 -0
- package/dist/react/router-events.test.d.ts +2 -0
- package/dist/react/router-events.test.d.ts.map +1 -0
- package/dist/react/router-events.test.js +180 -0
- package/dist/react/router-events.test.js.map +1 -0
- package/dist/react/router-ssr.test.d.ts +2 -0
- package/dist/react/router-ssr.test.d.ts.map +1 -0
- package/dist/react/router-ssr.test.js +88 -0
- package/dist/react/router-ssr.test.js.map +1 -0
- package/dist/react/router.d.ts +92 -0
- package/dist/react/router.d.ts.map +1 -0
- package/dist/react/router.js +362 -0
- package/dist/react/router.js.map +1 -0
- package/dist/react/router.rsc.d.ts +15 -0
- package/dist/react/router.rsc.d.ts.map +1 -0
- package/dist/react/router.rsc.js +85 -0
- package/dist/react/router.rsc.js.map +1 -0
- package/dist/react/sanitize-error.d.ts +2 -0
- package/dist/react/sanitize-error.d.ts.map +1 -0
- package/dist/react/sanitize-error.js +72 -0
- package/dist/react/sanitize-error.js.map +1 -0
- package/dist/react/sanitize-error.test.d.ts +2 -0
- package/dist/react/sanitize-error.test.d.ts.map +1 -0
- package/dist/react/sanitize-error.test.js +45 -0
- package/dist/react/sanitize-error.test.js.map +1 -0
- package/dist/react/scroll-restoration.d.ts +11 -0
- package/dist/react/scroll-restoration.d.ts.map +1 -0
- package/dist/react/scroll-restoration.js +136 -0
- package/dist/react/scroll-restoration.js.map +1 -0
- package/dist/react/spiceflow-dirs.d.ts +3 -0
- package/dist/react/spiceflow-dirs.d.ts.map +1 -0
- package/dist/react/spiceflow-dirs.js +6 -0
- package/dist/react/spiceflow-dirs.js.map +1 -0
- package/dist/react/spiceflow-dirs.rsc.d.ts +2 -0
- package/dist/react/spiceflow-dirs.rsc.d.ts.map +1 -0
- package/dist/react/spiceflow-dirs.rsc.js +5 -0
- package/dist/react/spiceflow-dirs.rsc.js.map +1 -0
- package/dist/react/ssr-error-fallback.test.d.ts +2 -0
- package/dist/react/ssr-error-fallback.test.d.ts.map +1 -0
- package/dist/react/ssr-error-fallback.test.js +75 -0
- package/dist/react/ssr-error-fallback.test.js.map +1 -0
- package/dist/react/transform.d.ts +5 -0
- package/dist/react/transform.d.ts.map +1 -0
- package/dist/react/transform.js +137 -0
- package/dist/react/transform.js.map +1 -0
- package/dist/react/transform.test.d.ts +2 -0
- package/dist/react/transform.test.d.ts.map +1 -0
- package/dist/react/transform.test.js +57 -0
- package/dist/react/transform.test.js.map +1 -0
- package/dist/render-react.test.d.ts +2 -0
- package/dist/render-react.test.d.ts.map +1 -0
- package/dist/render-react.test.js +388 -0
- package/dist/render-react.test.js.map +1 -0
- package/dist/router-context.d.ts +16 -0
- package/dist/router-context.d.ts.map +1 -0
- package/dist/router-context.default.d.ts +8 -0
- package/dist/router-context.default.d.ts.map +1 -0
- package/dist/router-context.default.js +26 -0
- package/dist/router-context.default.js.map +1 -0
- package/dist/router-context.js +20 -0
- package/dist/router-context.js.map +1 -0
- package/dist/rsc-runtime.d.ts +2 -0
- package/dist/rsc-runtime.d.ts.map +1 -0
- package/dist/rsc-runtime.default.d.ts +7 -0
- package/dist/rsc-runtime.default.d.ts.map +1 -0
- package/dist/rsc-runtime.default.js +15 -0
- package/dist/rsc-runtime.default.js.map +1 -0
- package/dist/rsc-runtime.js +4 -0
- package/dist/rsc-runtime.js.map +1 -0
- package/dist/server-file-guard.d.ts +3 -0
- package/dist/server-file-guard.d.ts.map +1 -0
- package/dist/server-file-guard.js +54 -0
- package/dist/server-file-guard.js.map +1 -0
- package/dist/simple.benchmark.js +1 -1
- package/dist/smoke.test.d.ts +2 -0
- package/dist/smoke.test.d.ts.map +1 -0
- package/dist/smoke.test.js +135 -0
- package/dist/smoke.test.js.map +1 -0
- package/dist/spiceflow.d.ts +114 -38
- package/dist/spiceflow.d.ts.map +1 -1
- package/dist/spiceflow.js +1419 -251
- package/dist/spiceflow.js.map +1 -1
- package/dist/spiceflow.test.js +1694 -242
- package/dist/spiceflow.test.js.map +1 -1
- package/dist/standard-schema.d.ts +32 -0
- package/dist/standard-schema.d.ts.map +1 -0
- package/dist/standard-schema.js +4 -0
- package/dist/standard-schema.js.map +1 -0
- package/dist/static-node.d.ts +2 -2
- package/dist/static-node.d.ts.map +1 -1
- package/dist/static-node.js +62 -17
- package/dist/static-node.js.map +1 -1
- package/dist/static-node.test.d.ts +2 -0
- package/dist/static-node.test.d.ts.map +1 -0
- package/dist/static-node.test.js +194 -0
- package/dist/static-node.test.js.map +1 -0
- package/dist/static.benchmark.js +2 -2
- package/dist/static.d.ts +10 -1
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +70 -51
- package/dist/static.js.map +1 -1
- package/dist/stream.test.js +31 -33
- package/dist/stream.test.js.map +1 -1
- package/dist/trace-dependencies.d.ts +9 -0
- package/dist/trace-dependencies.d.ts.map +1 -0
- package/dist/trace-dependencies.js +38 -0
- package/dist/trace-dependencies.js.map +1 -0
- package/dist/trie-router/node.d.ts +8 -0
- package/dist/trie-router/node.d.ts.map +1 -0
- package/dist/trie-router/node.js +172 -0
- package/dist/trie-router/node.js.map +1 -0
- package/dist/trie-router/node.test.d.ts +2 -0
- package/dist/trie-router/node.test.d.ts.map +1 -0
- package/dist/trie-router/node.test.js +859 -0
- package/dist/trie-router/node.test.js.map +1 -0
- package/dist/trie-router/router.d.ts +9 -0
- package/dist/trie-router/router.d.ts.map +1 -0
- package/dist/trie-router/router.js +23 -0
- package/dist/trie-router/router.js.map +1 -0
- package/dist/trie-router/url.d.ts +19 -0
- package/dist/trie-router/url.d.ts.map +1 -0
- package/dist/trie-router/url.js +235 -0
- package/dist/trie-router/url.js.map +1 -0
- package/dist/trie-router/utils.d.ts +34 -0
- package/dist/trie-router/utils.d.ts.map +1 -0
- package/dist/trie-router/utils.js +36 -0
- package/dist/trie-router/utils.js.map +1 -0
- package/dist/types.d.ts +67 -27
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -2
- package/dist/types.js.map +1 -1
- package/dist/types.test.js +6 -8
- package/dist/types.test.js.map +1 -1
- package/dist/utils.d.ts +4 -7
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +5 -6
- package/dist/utils.js.map +1 -1
- package/dist/vercel.d.ts +3 -0
- package/dist/vercel.d.ts.map +1 -0
- package/dist/vercel.js +134 -0
- package/dist/vercel.js.map +1 -0
- package/dist/vite-outdir.test.d.ts +2 -0
- package/dist/vite-outdir.test.d.ts.map +1 -0
- package/dist/vite-outdir.test.js +83 -0
- package/dist/vite-outdir.test.js.map +1 -0
- package/dist/vite.d.ts +13 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +868 -0
- package/dist/vite.js.map +1 -0
- package/dist/waitUntil.test.js +12 -12
- package/dist/waitUntil.test.js.map +1 -1
- package/dist/zod.test.js +2 -2
- package/package.json +94 -21
- package/src/_node-server.test.ts +72 -0
- package/src/_node-server.ts +144 -72
- package/src/action-context.ts +36 -0
- package/src/any-spiceflow-types.test.ts +253 -0
- package/src/base-path.ts +22 -0
- package/src/client/fetch.ts +98 -46
- package/src/client/index.ts +16 -3
- package/src/client/shared.ts +46 -6
- package/src/client/types.ts +6 -3
- package/src/client.test.ts +10 -13
- package/src/context.ts +29 -11
- package/src/copy-anything.ts +75 -0
- package/src/cors.test.ts +14 -5
- package/src/cors.ts +17 -8
- package/src/deployment-integration.test.ts +39 -0
- package/src/error.ts +25 -0
- package/src/federation/shared/react-dom-client.ts +1 -0
- package/src/federation/shared/react-dom.ts +5 -0
- package/src/federation/shared/react-jsx-runtime.ts +1 -0
- package/src/federation/shared/react.ts +10 -0
- package/src/federation/shared/spiceflow-react.ts +14 -0
- package/src/federation.default.ts +14 -0
- package/src/federation.rsc.test.ts +65 -0
- package/src/federation.rsc.ts +287 -0
- package/src/fetch-client.test.ts +89 -12
- package/src/get-available-port.ts +54 -0
- package/src/globals.d.ts +12 -0
- package/src/index.ts +25 -5
- package/src/instrumentation.test.ts +555 -0
- package/src/instrumentation.ts +141 -0
- package/src/mcp-client-transport.ts +11 -9
- package/src/mcp-transport.ts +1 -1
- package/src/mcp.test.ts +407 -0
- package/src/mcp.ts +9 -9
- package/src/middleware.test.ts +247 -13
- package/src/openapi-docs-examples.test.ts +598 -0
- package/src/openapi-to-mcp.ts +78 -35
- package/src/openapi.test.ts +25 -7
- package/src/openapi.ts +18 -11
- package/src/prevent-process-exit-if-busy.ts +2 -22
- package/src/query-coerce.ts +154 -0
- package/src/react/action-abort.ts +34 -0
- package/src/react/ambient.d.ts +40 -0
- package/src/react/components.tsx +335 -0
- package/src/react/context.tsx +52 -0
- package/src/react/deployment-id.rsc.ts +22 -0
- package/src/react/deployment-id.ts +7 -0
- package/src/react/deployment.test.ts +40 -0
- package/src/react/deployment.ts +61 -0
- package/src/react/document-title.tsx +13 -0
- package/src/react/entry.client.tsx +443 -0
- package/src/react/entry.rsc.tsx +37 -0
- package/src/react/entry.ssr.tsx +351 -0
- package/src/react/error-boundary.tsx +137 -0
- package/src/react/errors.tsx +103 -0
- package/src/react/esm-island.tsx +87 -0
- package/src/react/federated-payload.test.ts +148 -0
- package/src/react/federated-payload.ts +503 -0
- package/src/react/fetch.ts +76 -0
- package/src/react/flight-data-context.rsc.ts +6 -0
- package/src/react/flight-data-context.tsx +6 -0
- package/src/react/format-server-error.test.ts +88 -0
- package/src/react/format-server-error.ts +65 -0
- package/src/react/handle-ssr.rsc.ts +19 -0
- package/src/react/handle-ssr.ts +7 -0
- package/src/react/head-processing.tsx +158 -0
- package/src/react/head-tags.tsx +284 -0
- package/src/react/head.test.tsx +53 -0
- package/src/react/head.tsx +140 -0
- package/src/react/index.rsc.ts +22 -0
- package/src/react/index.ts +23 -0
- package/src/react/link.tsx +58 -0
- package/src/react/loader-utils.ts +33 -0
- package/src/react/prerender.ts +203 -0
- package/src/react/progress.test.ts +108 -0
- package/src/react/progress.tsx +246 -0
- package/src/react/react-cache.test.ts +47 -0
- package/src/react/remote-island.tsx +281 -0
- package/src/react/router-events.test.ts +216 -0
- package/src/react/router-ssr.test.tsx +121 -0
- package/src/react/router.rsc.ts +106 -0
- package/src/react/router.tsx +573 -0
- package/src/react/sanitize-error.test.ts +113 -0
- package/src/react/sanitize-error.ts +80 -0
- package/src/react/scroll-restoration.tsx +184 -0
- package/src/react/spiceflow-dirs.rsc.ts +5 -0
- package/src/react/spiceflow-dirs.ts +6 -0
- package/src/react/ssr-error-fallback.test.tsx +116 -0
- package/src/react/transform.test.ts +65 -0
- package/src/react/transform.ts +182 -0
- package/src/render-react.test.ts +419 -0
- package/src/router-context.default.ts +29 -0
- package/src/router-context.ts +35 -0
- package/src/rsc-runtime.default.ts +18 -0
- package/src/rsc-runtime.ts +10 -0
- package/src/server-file-guard.ts +60 -0
- package/src/simple.benchmark.ts +1 -1
- package/src/smoke.test.ts +160 -0
- package/src/spiceflow.test.ts +3286 -1394
- package/src/spiceflow.tsx +3433 -0
- package/src/standard-schema.ts +39 -0
- package/src/static-node.test.ts +246 -0
- package/src/static-node.ts +84 -19
- package/src/static.benchmark.ts +2 -2
- package/src/static.ts +97 -58
- package/src/stream.test.ts +49 -49
- package/src/trace-dependencies.ts +57 -0
- package/src/trie-router/node.test.ts +937 -0
- package/src/trie-router/node.ts +246 -0
- package/src/trie-router/router.ts +27 -0
- package/src/trie-router/url.ts +305 -0
- package/src/trie-router/utils.ts +69 -0
- package/src/types.test.ts +7 -9
- package/src/types.ts +249 -52
- package/src/utils.ts +10 -12
- package/src/vercel.ts +213 -0
- package/src/vite-outdir.test.ts +135 -0
- package/src/vite.tsx +1041 -0
- package/src/waitUntil.test.ts +25 -21
- package/src/zod.test.ts +2 -2
- 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>
|
|
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
|
|
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
|
-
-
|
|
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
|
|
31
|
+
npm install spiceflow@rsc
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
##
|
|
34
|
+
## AI Agents
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
To let your AI coding agent know how to use spiceflow, run:
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
```bash
|
|
39
|
+
npx -y skills add remorses/spiceflow
|
|
40
|
+
```
|
|
39
41
|
|
|
40
|
-
|
|
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
|
-
.
|
|
45
|
-
|
|
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
|
-
.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
>
|
|
76
|
+
<details>
|
|
77
|
+
<summary>When to use .route() vs .get()/.post()</summary>
|
|
64
78
|
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
##
|
|
111
|
+
## `use client` trap in optimized `node_modules` dependencies
|
|
93
112
|
|
|
94
|
-
|
|
113
|
+
**This section is about published dependencies from `node_modules`, not your app's own `src/` files.**
|
|
95
114
|
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
142
|
+
**Safer pattern**
|
|
124
143
|
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
// Throw Response for errors
|
|
176
|
-
throw new Response('User already exists', { status: 409 })
|
|
177
|
-
}
|
|
157
|
+
### How to debug this
|
|
178
158
|
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
console.error('Error:', error) // Error handling
|
|
201
|
-
return
|
|
202
|
-
}
|
|
179
|
+
Useful search pattern:
|
|
203
180
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
181
|
+
```bash
|
|
182
|
+
rg -n "extends .*Component|useState|useEffect|prefetchDNS|preconnect|react-server" node_modules/.vite
|
|
207
183
|
```
|
|
208
184
|
|
|
209
|
-
|
|
185
|
+
## Returning JSON
|
|
210
186
|
|
|
211
|
-
|
|
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
|
-
|
|
189
|
+
```ts
|
|
190
|
+
import { Spiceflow } from 'spiceflow'
|
|
216
191
|
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
- Better async generator support by using SSE
|
|
209
|
+
```ts
|
|
210
|
+
import { Spiceflow, json } from 'spiceflow'
|
|
224
211
|
|
|
225
|
-
|
|
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
|
-
|
|
215
|
+
// Avoid — Response.json() erases the type, fetch client sees unknown
|
|
216
|
+
throw Response.json({ error: 'Not found' }, { status: 404 })
|
|
217
|
+
```
|
|
228
218
|
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
>
|
|
259
|
-
>
|
|
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
|
-
|
|
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 {
|
|
291
|
-
import { Spiceflow } from 'spiceflow'
|
|
284
|
+
import { Spiceflow, json } from 'spiceflow'
|
|
292
285
|
import { z } from 'zod'
|
|
293
286
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
+
## Middleware
|
|
368
318
|
|
|
369
|
-
|
|
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
|
-
|
|
377
|
-
.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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: '/
|
|
399
|
-
handler(
|
|
400
|
-
return {
|
|
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: '/
|
|
406
|
-
|
|
407
|
-
|
|
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: '
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
import { createSpiceflowFetch } from 'spiceflow/client'
|
|
429
|
-
import type { App } from './server'
|
|
381
|
+
<details>
|
|
382
|
+
<summary>Priority rules</summary>
|
|
430
383
|
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
411
|
+
In this example, `./public/logo.png` wins over `./uploads/logo.png` because `./public` is registered first.
|
|
467
412
|
|
|
468
|
-
|
|
413
|
+
> Vite client build assets (`dist/client`) are served automatically in production — no need to register a `serveStatic` middleware for them.
|
|
469
414
|
|
|
470
|
-
|
|
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
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
586
|
+
### Vite Base Path
|
|
777
587
|
|
|
778
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
796
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
609
|
+
</details>
|
|
826
610
|
|
|
827
|
-
|
|
828
|
-
|
|
611
|
+
<details>
|
|
612
|
+
<summary>What gets auto-prepended and what doesn't</summary>
|
|
829
613
|
|
|
830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
839
|
-
import { Spiceflow } from 'spiceflow'
|
|
623
|
+
**What does NOT get auto-prepended:**
|
|
840
624
|
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
630
|
+
</details>
|
|
847
631
|
|
|
848
|
-
|
|
632
|
+
## Fetch Client
|
|
849
633
|
|
|
850
|
-
-
|
|
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
|
-
|
|
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 {
|
|
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: '/
|
|
646
|
+
path: '/hello',
|
|
865
647
|
handler() {
|
|
866
|
-
|
|
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: '/
|
|
872
|
-
handler() {
|
|
873
|
-
return
|
|
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: '/
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
async
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
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
|
-
|
|
737
|
+
The fetch client supports configuration options like headers, retries, onRequest/onResponse hooks, and custom fetch.
|
|
914
738
|
|
|
915
|
-
|
|
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 {
|
|
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: '/
|
|
928
|
-
handler() {
|
|
929
|
-
|
|
930
|
-
|
|
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: '/
|
|
938
|
-
handler({ request }) {
|
|
939
|
-
|
|
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
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
##
|
|
783
|
+
## Cookies
|
|
953
784
|
|
|
954
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
1036
|
-
import { MiddlewareHandler } from 'spiceflow/dist/types'
|
|
855
|
+
const listener = await app.listen(3000)
|
|
1037
856
|
|
|
1038
|
-
|
|
857
|
+
console.log(`Listening on port ${listener.port}`)
|
|
1039
858
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
changeOrigin = false,
|
|
1043
|
-
}): MiddlewareHandler {
|
|
1044
|
-
return async ({ request }) => {
|
|
1045
|
-
const url = new URL(request.url)
|
|
859
|
+
await listener.stop()
|
|
860
|
+
```
|
|
1046
861
|
|
|
1047
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1096
|
-
if (!session) {
|
|
1097
|
-
return
|
|
1098
|
-
}
|
|
1099
|
-
state.session = session
|
|
1100
|
-
const response = await next()
|
|
967
|
+
### shadcn/ui
|
|
1101
968
|
|
|
1102
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1051
|
+
<details>
|
|
1052
|
+
<summary>Why not app.href() inside the chain?</summary>
|
|
1125
1053
|
|
|
1126
|
-
|
|
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
|
-
|
|
1129
|
-
import { Spiceflow } from 'spiceflow'
|
|
1056
|
+
</details>
|
|
1130
1057
|
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
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: '/
|
|
1167
|
-
|
|
1168
|
-
|
|
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
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1909
|
+
### Streaming UI from Server Actions
|
|
1192
1910
|
|
|
1193
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
import {
|
|
1202
|
-
import {
|
|
1203
|
-
import {
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
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
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
-
.
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
-
.
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2033
|
+
`redirect()` accepts an optional second argument for custom status codes and headers:
|
|
1276
2034
|
|
|
1277
|
-
|
|
2035
|
+
```tsx
|
|
2036
|
+
// 301 permanent redirect
|
|
2037
|
+
throw redirect('/new-url', { status: 301 })
|
|
1278
2038
|
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
2039
|
+
// Redirect with custom headers
|
|
2040
|
+
throw redirect('/login', {
|
|
2041
|
+
headers: { 'set-cookie': 'session=; Max-Age=0' },
|
|
2042
|
+
})
|
|
2043
|
+
```
|
|
1282
2044
|
|
|
1283
|
-
|
|
2045
|
+
<details>
|
|
2046
|
+
<summary>Response status, headers, and HTTP behavior</summary>
|
|
1284
2047
|
|
|
1285
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1310
|
-
```
|
|
2054
|
+
</details>
|
|
1311
2055
|
|
|
1312
|
-
|
|
2056
|
+
### Code Splitting
|
|
1313
2057
|
|
|
1314
|
-
|
|
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
|
-
|
|
2060
|
+
<details>
|
|
2061
|
+
<summary>How it works</summary>
|
|
1317
2062
|
|
|
1318
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1329
|
-
|
|
1330
|
-
path: '/hello',
|
|
1331
|
-
handler() {
|
|
1332
|
-
return 'Hello World'
|
|
1333
|
-
},
|
|
1334
|
-
})
|
|
2067
|
+
<details>
|
|
2068
|
+
<summary>Barrel file pitfall</summary>
|
|
1335
2069
|
|
|
1336
|
-
|
|
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
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
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
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2101
|
+
</details>
|
|
1370
2102
|
|
|
1371
|
-
|
|
2103
|
+
### Directory Paths
|
|
1372
2104
|
|
|
1373
|
-
|
|
1374
|
-
import { Spiceflow } from 'spiceflow'
|
|
1375
|
-
import { z } from 'zod'
|
|
2105
|
+
> Only available when using the Vite plugin.
|
|
1376
2106
|
|
|
1377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
2123
|
+
| | `publicDir` | `distDir` |
|
|
2124
|
+
|---|---|---|
|
|
2125
|
+
| **Dev** | `<cwd>/public` | `<cwd>` |
|
|
2126
|
+
| **Production** | `<outDir>/client` (where Vite copies public/ contents) | `<outDir>` |
|
|
1412
2127
|
|
|
1413
|
-
|
|
2128
|
+
### Remote Components & Federation
|
|
1414
2129
|
|
|
1415
|
-
|
|
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
|
-
```
|
|
1418
|
-
|
|
2132
|
+
```tsx
|
|
2133
|
+
// remote app
|
|
2134
|
+
import { encodeFederationPayload } from 'spiceflow/federation'
|
|
1419
2135
|
|
|
1420
|
-
|
|
1421
|
-
|
|
2136
|
+
.get('/api/chart', async () => {
|
|
2137
|
+
return await encodeFederationPayload(<Chart dataSource="revenue" />)
|
|
1422
2138
|
})
|
|
1423
2139
|
|
|
1424
|
-
//
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
console.log('Stream data:', data)
|
|
1428
|
-
}
|
|
2140
|
+
// host app
|
|
2141
|
+
import { Suspense } from 'react'
|
|
2142
|
+
import { RenderFederatedPayload } from 'spiceflow/react'
|
|
1429
2143
|
|
|
1430
|
-
|
|
1431
|
-
const response = await
|
|
1432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1440
|
-
import { Spiceflow } from 'spiceflow'
|
|
1441
|
-
import { parse, serialize } from 'cookie'
|
|
2156
|
+
## Model Context Protocol (MCP)
|
|
1442
2157
|
|
|
1443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1522
|
-
|
|
2170
|
+
<details>
|
|
2171
|
+
<summary>Edge cases and encryption</summary>
|
|
1523
2172
|
|
|
1524
|
-
|
|
2173
|
+
Cross-deployment requests can fail in two cases:
|
|
1525
2174
|
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2180
|
+
</details>
|
|
1543
2181
|
|
|
1544
|
-
|
|
1545
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1569
|
-
},
|
|
1570
|
-
})
|
|
1571
|
-
```
|
|
2187
|
+
The deployment ID uses the `#deployment-id` import map in `package.json` with environment-conditional resolution:
|
|
1572
2188
|
|
|
1573
|
-
|
|
2189
|
+
- **`react-server`** — imports from `virtual:spiceflow-deployment-id` (the build timestamp baked in by Vite)
|
|
2190
|
+
- **`default`** (browser, tests) — returns `''`
|
|
1574
2191
|
|
|
1575
|
-
|
|
2192
|
+
In dev mode the RSC loader also returns `''`.
|
|
1576
2193
|
|
|
1577
|
-
|
|
2194
|
+
</details>
|
|
1578
2195
|
|
|
1579
|
-
|
|
1580
|
-
import { Spiceflow } from 'spiceflow'
|
|
2196
|
+
## Node.js Handlers
|
|
1581
2197
|
|
|
1582
|
-
|
|
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
|
-
|
|
1589
|
-
|
|
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
|
-
|
|
1597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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().
|
|
1610
|
-
|
|
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
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
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
|
|
1635
|
-
return app.handle(request
|
|
2229
|
+
fetch(request: Request) {
|
|
2230
|
+
return app.handle(request)
|
|
1636
2231
|
},
|
|
1637
2232
|
}
|
|
1638
2233
|
```
|
|
1639
2234
|
|
|
1640
|
-
## Next.js
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1670
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2289
|
+
</details>
|
|
1692
2290
|
|
|
1693
|
-
##
|
|
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
|
-
|
|
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,
|
|
2303
|
+
import { Spiceflow, AnySpiceflow } from 'spiceflow'
|
|
1699
2304
|
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2344
|
+
## Comparisons
|
|
2345
|
+
|
|
2346
|
+
#### Elysia
|
|
2347
|
+
|
|
2348
|
+
This project was born as a fork of Elysia with several changes:
|
|
1722
2349
|
|
|
1723
|
-
|
|
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
|
-
|
|
2354
|
+
#### Hono
|
|
1726
2355
|
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
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`
|