@mandujs/core 0.54.32 → 0.55.0-beta.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 +654 -654
- package/package.json +106 -210
- package/scripts/postinstall-lock.ts +153 -153
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +15 -15
- package/src/a11y/types.ts +125 -125
- package/src/agent/__tests__/apply.test.ts +224 -0
- package/src/agent/__tests__/context.test.ts +97 -98
- package/src/agent/apply.ts +1031 -0
- package/src/agent/context.ts +552 -552
- package/src/agent/index.ts +4 -3
- package/src/agent/plan.ts +161 -243
- package/src/agent/repair.ts +239 -162
- package/src/agent/sync.ts +197 -198
- package/src/agent/types.ts +242 -94
- package/src/agent/verify.ts +55 -55
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +15 -5
- package/src/auth/__tests__/reset.test.ts +2 -2
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +14 -2
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__snapshots__/build.test.ts.snap +5 -5
- package/src/bundler/__tests__/build-runner.ts +166 -166
- package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -524
- package/src/bundler/__tests__/cold-start.test.ts +60 -60
- package/src/bundler/__tests__/css.test.ts +49 -20
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +24 -24
- package/src/bundler/__tests__/generation.test.ts +447 -0
- package/src/bundler/__tests__/hdr.test.ts +24 -18
- package/src/bundler/__tests__/hmr-client.test.ts +62 -24
- package/src/bundler/__tests__/jsx-runtime-shim.test.ts +179 -140
- package/src/bundler/__tests__/manifest-schema.test.ts +305 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -42
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/analyzer.ts +15 -15
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +898 -915
- package/src/bundler/build.ts +113 -24
- package/src/bundler/client-boundary-transform.ts +977 -977
- package/src/bundler/css.ts +65 -44
- package/src/bundler/dev.ts +105 -73
- package/src/bundler/fast-refresh-preamble.ts +47 -47
- package/src/bundler/generation.ts +602 -0
- package/src/bundler/index.ts +3 -3
- package/src/bundler/manifest-schema.ts +55 -40
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +16 -15
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/prerender.ts +29 -9
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +1 -1
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +58 -56
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/props-serialization.test.ts +37 -37
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +2 -2
- package/src/client/index.ts +1 -1
- package/src/client/island.ts +79 -79
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/props-serialization.ts +233 -233
- package/src/client/runtime-entry.ts +598 -598
- package/src/client/runtime.ts +1 -1
- package/src/client/serialize.ts +50 -50
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image-compat.ts +3 -0
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +1 -1
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +9 -9
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/generate-types.ts +1 -1
- package/src/content/index.ts +2 -2
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +9 -9
- package/src/db/index.ts +2 -2
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +82 -82
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/smoke.test.ts +2 -2
- package/src/desktop/__tests__/webview-fallback.test.ts +12 -32
- package/src/desktop/__tests__/window.test.ts +20 -99
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/webview-fallback.ts +17 -4
- package/src/desktop/window.ts +17 -8
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +1 -1
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/diagnose/__tests__/checks.test.ts +136 -136
- package/src/diagnose/checks.ts +185 -185
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +10 -10
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +5 -1
- package/src/filling/auth.ts +308 -308
- package/src/filling/body-parse.test.ts +60 -60
- package/src/filling/cookie-codec.ts +5 -3
- package/src/filling/deps.ts +265 -265
- package/src/filling/head-method.test.ts +154 -154
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/generate.ts +30 -30
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.test.ts +48 -48
- package/src/generator/templates.ts +219 -219
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/check.ts +39 -24
- package/src/guard/config-guard.ts +13 -13
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/fs-routes-policy.ts +51 -51
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/index.ts +11 -11
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/__tests__/id.test.ts +3 -3
- package/src/id/index.ts +105 -105
- package/src/index.ts +9 -48
- package/src/internal/client-boundary.ts +266 -266
- package/src/internal/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -779
- package/src/kitchen/api/bundle-inspector.ts +179 -0
- package/src/kitchen/api/errors-grouping.ts +126 -126
- package/src/kitchen/api/file-api.ts +11 -11
- package/src/kitchen/kitchen-handler.ts +36 -0
- package/src/kitchen/kitchen-ui.ts +456 -0
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/perf/user-marks.ts +1 -1
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +7 -7
- package/src/resource/__tests__/schema.test.ts +14 -14
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +165 -165
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +146 -146
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/generator-schema.ts +11 -11
- package/src/resource/generators/slot.ts +72 -72
- package/src/resource/schema.ts +21 -21
- package/src/router/client-entry.test.ts +227 -227
- package/src/router/client-entry.ts +218 -218
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.test.ts +532 -532
- package/src/router/fs-routes.ts +53 -53
- package/src/router/fs-scanner.ts +216 -216
- package/src/router/fs-types.ts +19 -19
- package/src/router/route-source-analyzer.ts +521 -521
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/inline-client-hydration.test.ts +237 -237
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
- package/src/runtime/__tests__/page-render-response.test.ts +517 -517
- package/src/runtime/__tests__/request-middleware.test.ts +70 -70
- package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -81
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/devtools-adapter.ts +68 -68
- package/src/runtime/escape.ts +34 -34
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/fast-refresh-types.ts +1 -1
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +30 -11
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/observability-lifecycle.ts +290 -290
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/page-render-response.ts +287 -274
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/rate-limit.ts +1 -1
- package/src/runtime/redirect.ts +1 -1
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/request-middleware.ts +31 -31
- package/src/runtime/router.test.ts +4 -4
- package/src/runtime/router.ts +10 -10
- package/src/runtime/server.ts +127 -211
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/ssr.ts +43 -17
- package/src/runtime/static-files.ts +369 -289
- package/src/runtime/streaming-ssr.ts +219 -180
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/__tests__/scheduler.test.ts +12 -11
- package/src/scheduler/index.ts +11 -5
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/spec/schema.ts +35 -35
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/index.ts +9 -3
- package/src/testing/lcov.ts +192 -0
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/__tests__/watcher.test.ts +59 -59
- package/src/watcher/watcher.ts +61 -61
package/src/client/use-sse.ts
CHANGED
|
@@ -1,378 +1,378 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mandu useSSE Hook - ReadableStream SSE reading for island components
|
|
3
|
-
*
|
|
4
|
-
* Problem: When a React component (inside createRoot) reads a ReadableStream
|
|
5
|
-
* in a tight `while(true) { await reader.read(); setState(...); }` loop,
|
|
6
|
-
* React 19's internal scheduler uses `queueMicrotask` to process state updates.
|
|
7
|
-
* Combined with fast SSE token streams, the microtask queue never drains,
|
|
8
|
-
* starving the macrotask queue (DOM painting, user input, MessageChannel).
|
|
9
|
-
* This effectively freezes the main thread.
|
|
10
|
-
*
|
|
11
|
-
* Solution: This hook yields to the macrotask queue between chunks using
|
|
12
|
-
* `setTimeout(0)`, allowing the browser to paint and process user input
|
|
13
|
-
* between state updates. Chunks arriving during the yield are coalesced
|
|
14
|
-
* into a single state update to minimize re-renders.
|
|
15
|
-
*
|
|
16
|
-
* @module client/use-sse
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { useState, useCallback, useRef, useEffect } from "react";
|
|
20
|
-
|
|
21
|
-
// ============================================================================
|
|
22
|
-
// Types
|
|
23
|
-
// ============================================================================
|
|
24
|
-
|
|
25
|
-
export interface UseSSEOptions {
|
|
26
|
-
/**
|
|
27
|
-
* Called for each SSE chunk (after TextDecoder).
|
|
28
|
-
* Return the new accumulated value to set as state.
|
|
29
|
-
* @param accumulated - Current accumulated value
|
|
30
|
-
* @param chunk - New text chunk from the stream
|
|
31
|
-
* @returns Updated accumulated value
|
|
32
|
-
*/
|
|
33
|
-
onChunk?: (accumulated: string, chunk: string) => string;
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Called when the stream completes.
|
|
37
|
-
* @param finalValue - The final accumulated value
|
|
38
|
-
*/
|
|
39
|
-
onComplete?: (finalValue: string) => void;
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Called when the stream errors.
|
|
43
|
-
* @param error - The error that occurred
|
|
44
|
-
*/
|
|
45
|
-
onError?: (error: Error) => void;
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Minimum interval between state updates in ms.
|
|
49
|
-
* Higher values reduce re-renders but increase perceived latency.
|
|
50
|
-
* Default: 0 (yield once per chunk via setTimeout(0))
|
|
51
|
-
*/
|
|
52
|
-
throttleMs?: number;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface UseSSEReturn {
|
|
56
|
-
/** Current accumulated stream data */
|
|
57
|
-
data: string;
|
|
58
|
-
|
|
59
|
-
/** Whether the stream is currently active */
|
|
60
|
-
isStreaming: boolean;
|
|
61
|
-
|
|
62
|
-
/** Error if the stream failed */
|
|
63
|
-
error: Error | null;
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Start reading an SSE stream.
|
|
67
|
-
* Pass a fetch Response or a ReadableStream<Uint8Array>.
|
|
68
|
-
*/
|
|
69
|
-
start: (source: Response | ReadableStream<Uint8Array>) => void;
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Abort the current stream.
|
|
73
|
-
*/
|
|
74
|
-
abort: () => void;
|
|
75
|
-
|
|
76
|
-
/** Reset state (data, error, isStreaming) */
|
|
77
|
-
reset: () => void;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ============================================================================
|
|
81
|
-
// Helpers
|
|
82
|
-
// ============================================================================
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Yield to the macrotask queue, allowing the browser to paint and
|
|
86
|
-
* process user input. This breaks the microtask starvation cycle
|
|
87
|
-
* caused by tight `await reader.read()` + `setState()` loops.
|
|
88
|
-
*/
|
|
89
|
-
function yieldToMacrotask(): Promise<void> {
|
|
90
|
-
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ============================================================================
|
|
94
|
-
// Hook
|
|
95
|
-
// ============================================================================
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* React hook for reading SSE / ReadableStream data inside island components
|
|
99
|
-
* without blocking the main thread.
|
|
100
|
-
*
|
|
101
|
-
* @example
|
|
102
|
-
* ```tsx
|
|
103
|
-
* import { useSSE } from "@mandujs/core/client";
|
|
104
|
-
*
|
|
105
|
-
* function ChatIsland() {
|
|
106
|
-
* const { data, isStreaming, start } = useSSE({
|
|
107
|
-
* onComplete: (text) => console.log("Done:", text),
|
|
108
|
-
* });
|
|
109
|
-
*
|
|
110
|
-
* const sendMessage = async () => {
|
|
111
|
-
* const res = await fetch("/api/chat", { method: "POST", body: ... });
|
|
112
|
-
* start(res);
|
|
113
|
-
* };
|
|
114
|
-
*
|
|
115
|
-
* return (
|
|
116
|
-
* <div>
|
|
117
|
-
* <p>{data}</p>
|
|
118
|
-
* {isStreaming && <span>streaming...</span>}
|
|
119
|
-
* <button onClick={sendMessage} disabled={isStreaming}>Send</button>
|
|
120
|
-
* </div>
|
|
121
|
-
* );
|
|
122
|
-
* }
|
|
123
|
-
* ```
|
|
124
|
-
*/
|
|
125
|
-
export function useSSE(options: UseSSEOptions = {}): UseSSEReturn {
|
|
126
|
-
const {
|
|
127
|
-
onChunk = (acc: string, chunk: string) => acc + chunk,
|
|
128
|
-
onComplete,
|
|
129
|
-
onError,
|
|
130
|
-
throttleMs = 0,
|
|
131
|
-
} = options;
|
|
132
|
-
|
|
133
|
-
const [data, setData] = useState("");
|
|
134
|
-
const [isStreaming, setIsStreaming] = useState(false);
|
|
135
|
-
const [error, setError] = useState<Error | null>(null);
|
|
136
|
-
|
|
137
|
-
// Refs for the current stream session (allow abort & prevent stale closures)
|
|
138
|
-
const abortRef = useRef<AbortController | null>(null);
|
|
139
|
-
const accumulatorRef = useRef("");
|
|
140
|
-
const pendingChunksRef = useRef("");
|
|
141
|
-
const flushScheduledRef = useRef(false);
|
|
142
|
-
const optionsRef = useRef({ onChunk, onComplete, onError, throttleMs });
|
|
143
|
-
|
|
144
|
-
// Keep options ref in sync
|
|
145
|
-
useEffect(() => {
|
|
146
|
-
optionsRef.current = { onChunk, onComplete, onError, throttleMs };
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
// Cleanup on unmount
|
|
150
|
-
useEffect(() => {
|
|
151
|
-
return () => {
|
|
152
|
-
if (abortRef.current) {
|
|
153
|
-
abortRef.current.abort();
|
|
154
|
-
abortRef.current = null;
|
|
155
|
-
}
|
|
156
|
-
};
|
|
157
|
-
}, []);
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Flush pending chunks as a single batched state update.
|
|
161
|
-
* Uses setTimeout(0) to yield to the macrotask queue first.
|
|
162
|
-
*/
|
|
163
|
-
const flushPending = useCallback(() => {
|
|
164
|
-
if (!flushScheduledRef.current) return;
|
|
165
|
-
|
|
166
|
-
const pending = pendingChunksRef.current;
|
|
167
|
-
if (pending) {
|
|
168
|
-
const { onChunk: chunkFn } = optionsRef.current;
|
|
169
|
-
accumulatorRef.current = chunkFn(accumulatorRef.current, pending);
|
|
170
|
-
pendingChunksRef.current = "";
|
|
171
|
-
setData(accumulatorRef.current);
|
|
172
|
-
}
|
|
173
|
-
flushScheduledRef.current = false;
|
|
174
|
-
}, []);
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Schedule a flush via setTimeout to yield to macrotask queue.
|
|
178
|
-
*/
|
|
179
|
-
const scheduleFlush = useCallback(() => {
|
|
180
|
-
if (!flushScheduledRef.current) {
|
|
181
|
-
flushScheduledRef.current = true;
|
|
182
|
-
setTimeout(flushPending, optionsRef.current.throttleMs);
|
|
183
|
-
}
|
|
184
|
-
}, [flushPending]);
|
|
185
|
-
|
|
186
|
-
const start = useCallback(
|
|
187
|
-
(source: Response | ReadableStream<Uint8Array>) => {
|
|
188
|
-
// Abort any existing stream
|
|
189
|
-
if (abortRef.current) {
|
|
190
|
-
abortRef.current.abort();
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const controller = new AbortController();
|
|
194
|
-
abortRef.current = controller;
|
|
195
|
-
accumulatorRef.current = "";
|
|
196
|
-
pendingChunksRef.current = "";
|
|
197
|
-
flushScheduledRef.current = false;
|
|
198
|
-
|
|
199
|
-
setData("");
|
|
200
|
-
setError(null);
|
|
201
|
-
setIsStreaming(true);
|
|
202
|
-
|
|
203
|
-
const body =
|
|
204
|
-
source instanceof Response ? source.body : source;
|
|
205
|
-
|
|
206
|
-
if (!body) {
|
|
207
|
-
setIsStreaming(false);
|
|
208
|
-
setError(new Error("Response has no body"));
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Start reading in a properly-yielding async loop
|
|
213
|
-
void (async () => {
|
|
214
|
-
const reader = body.getReader();
|
|
215
|
-
const decoder = new TextDecoder();
|
|
216
|
-
|
|
217
|
-
try {
|
|
218
|
-
while (true) {
|
|
219
|
-
// Check for abort
|
|
220
|
-
if (controller.signal.aborted) {
|
|
221
|
-
await reader.cancel();
|
|
222
|
-
break;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const { done, value } = await reader.read();
|
|
226
|
-
if (done) break;
|
|
227
|
-
|
|
228
|
-
// Check for abort again (may have been aborted during read)
|
|
229
|
-
if (controller.signal.aborted) {
|
|
230
|
-
await reader.cancel();
|
|
231
|
-
break;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const text = decoder.decode(value, { stream: true });
|
|
235
|
-
if (text) {
|
|
236
|
-
// Accumulate chunks and schedule a batched flush
|
|
237
|
-
pendingChunksRef.current += text;
|
|
238
|
-
scheduleFlush();
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// CRITICAL: Yield to the macrotask queue to prevent microtask
|
|
242
|
-
// starvation. Without this, the tight loop of
|
|
243
|
-
// `await reader.read() -> setState -> React microtask -> next read`
|
|
244
|
-
// starves the browser's macrotask queue, freezing the UI.
|
|
245
|
-
await yieldToMacrotask();
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Final flush for any remaining chunks
|
|
249
|
-
if (pendingChunksRef.current) {
|
|
250
|
-
flushScheduledRef.current = true;
|
|
251
|
-
flushPending();
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (!controller.signal.aborted) {
|
|
255
|
-
setIsStreaming(false);
|
|
256
|
-
const opts = optionsRef.current;
|
|
257
|
-
if (opts.onComplete) {
|
|
258
|
-
opts.onComplete(accumulatorRef.current);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
} catch (err) {
|
|
262
|
-
if (
|
|
263
|
-
!controller.signal.aborted &&
|
|
264
|
-
!(err instanceof DOMException && err.name === "AbortError")
|
|
265
|
-
) {
|
|
266
|
-
const error =
|
|
267
|
-
err instanceof Error ? err : new Error(String(err));
|
|
268
|
-
setError(error);
|
|
269
|
-
setIsStreaming(false);
|
|
270
|
-
const opts = optionsRef.current;
|
|
271
|
-
if (opts.onError) {
|
|
272
|
-
opts.onError(error);
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
})();
|
|
277
|
-
},
|
|
278
|
-
[scheduleFlush, flushPending]
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
const abort = useCallback(() => {
|
|
282
|
-
if (abortRef.current) {
|
|
283
|
-
abortRef.current.abort();
|
|
284
|
-
abortRef.current = null;
|
|
285
|
-
}
|
|
286
|
-
setIsStreaming(false);
|
|
287
|
-
}, []);
|
|
288
|
-
|
|
289
|
-
const reset = useCallback(() => {
|
|
290
|
-
abort();
|
|
291
|
-
setData("");
|
|
292
|
-
setError(null);
|
|
293
|
-
accumulatorRef.current = "";
|
|
294
|
-
pendingChunksRef.current = "";
|
|
295
|
-
flushScheduledRef.current = false;
|
|
296
|
-
}, [abort]);
|
|
297
|
-
|
|
298
|
-
return { data, isStreaming, error, start, abort, reset };
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// ============================================================================
|
|
302
|
-
// Low-level utility (for non-hook usage)
|
|
303
|
-
// ============================================================================
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Read a ReadableStream with macrotask yielding, suitable for use
|
|
307
|
-
* outside of React components (e.g., in plain event handlers).
|
|
308
|
-
*
|
|
309
|
-
* @example
|
|
310
|
-
* ```ts
|
|
311
|
-
* import { readStreamWithYield } from "@mandujs/core/client";
|
|
312
|
-
*
|
|
313
|
-
* const response = await fetch("/api/stream");
|
|
314
|
-
* await readStreamWithYield(response.body!, {
|
|
315
|
-
* onChunk: (text) => {
|
|
316
|
-
* // update DOM directly
|
|
317
|
-
* el.textContent += text;
|
|
318
|
-
* },
|
|
319
|
-
* onDone: () => console.log("done"),
|
|
320
|
-
* });
|
|
321
|
-
* ```
|
|
322
|
-
*/
|
|
323
|
-
export interface ReadStreamOptions {
|
|
324
|
-
/** Called for each decoded text chunk */
|
|
325
|
-
onChunk: (text: string) => void;
|
|
326
|
-
/** Called when the stream is complete */
|
|
327
|
-
onDone?: () => void;
|
|
328
|
-
/** Called on error */
|
|
329
|
-
onError?: (error: Error) => void;
|
|
330
|
-
/** AbortSignal to cancel reading */
|
|
331
|
-
signal?: AbortSignal;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
export async function readStreamWithYield(
|
|
335
|
-
stream: ReadableStream<Uint8Array>,
|
|
336
|
-
options: ReadStreamOptions
|
|
337
|
-
): Promise<void> {
|
|
338
|
-
const { onChunk, onDone, onError, signal } = options;
|
|
339
|
-
const reader = stream.getReader();
|
|
340
|
-
const decoder = new TextDecoder();
|
|
341
|
-
|
|
342
|
-
try {
|
|
343
|
-
while (true) {
|
|
344
|
-
if (signal?.aborted) {
|
|
345
|
-
await reader.cancel();
|
|
346
|
-
break;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const { done, value } = await reader.read();
|
|
350
|
-
if (done) break;
|
|
351
|
-
|
|
352
|
-
if (signal?.aborted) {
|
|
353
|
-
await reader.cancel();
|
|
354
|
-
break;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const text = decoder.decode(value, { stream: true });
|
|
358
|
-
if (text) {
|
|
359
|
-
onChunk(text);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Yield to macrotask queue
|
|
363
|
-
await yieldToMacrotask();
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
if (!signal?.aborted) {
|
|
367
|
-
onDone?.();
|
|
368
|
-
}
|
|
369
|
-
} catch (err) {
|
|
370
|
-
if (
|
|
371
|
-
!signal?.aborted &&
|
|
372
|
-
!(err instanceof DOMException && err.name === "AbortError")
|
|
373
|
-
) {
|
|
374
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
375
|
-
onError?.(error);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Mandu useSSE Hook - ReadableStream SSE reading for island components
|
|
3
|
+
*
|
|
4
|
+
* Problem: When a React component (inside createRoot) reads a ReadableStream
|
|
5
|
+
* in a tight `while(true) { await reader.read(); setState(...); }` loop,
|
|
6
|
+
* React 19's internal scheduler uses `queueMicrotask` to process state updates.
|
|
7
|
+
* Combined with fast SSE token streams, the microtask queue never drains,
|
|
8
|
+
* starving the macrotask queue (DOM painting, user input, MessageChannel).
|
|
9
|
+
* This effectively freezes the main thread.
|
|
10
|
+
*
|
|
11
|
+
* Solution: This hook yields to the macrotask queue between chunks using
|
|
12
|
+
* `setTimeout(0)`, allowing the browser to paint and process user input
|
|
13
|
+
* between state updates. Chunks arriving during the yield are coalesced
|
|
14
|
+
* into a single state update to minimize re-renders.
|
|
15
|
+
*
|
|
16
|
+
* @module client/use-sse
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Types
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
export interface UseSSEOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Called for each SSE chunk (after TextDecoder).
|
|
28
|
+
* Return the new accumulated value to set as state.
|
|
29
|
+
* @param accumulated - Current accumulated value
|
|
30
|
+
* @param chunk - New text chunk from the stream
|
|
31
|
+
* @returns Updated accumulated value
|
|
32
|
+
*/
|
|
33
|
+
onChunk?: (accumulated: string, chunk: string) => string;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Called when the stream completes.
|
|
37
|
+
* @param finalValue - The final accumulated value
|
|
38
|
+
*/
|
|
39
|
+
onComplete?: (finalValue: string) => void;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Called when the stream errors.
|
|
43
|
+
* @param error - The error that occurred
|
|
44
|
+
*/
|
|
45
|
+
onError?: (error: Error) => void;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Minimum interval between state updates in ms.
|
|
49
|
+
* Higher values reduce re-renders but increase perceived latency.
|
|
50
|
+
* Default: 0 (yield once per chunk via setTimeout(0))
|
|
51
|
+
*/
|
|
52
|
+
throttleMs?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface UseSSEReturn {
|
|
56
|
+
/** Current accumulated stream data */
|
|
57
|
+
data: string;
|
|
58
|
+
|
|
59
|
+
/** Whether the stream is currently active */
|
|
60
|
+
isStreaming: boolean;
|
|
61
|
+
|
|
62
|
+
/** Error if the stream failed */
|
|
63
|
+
error: Error | null;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Start reading an SSE stream.
|
|
67
|
+
* Pass a fetch Response or a ReadableStream<Uint8Array>.
|
|
68
|
+
*/
|
|
69
|
+
start: (source: Response | ReadableStream<Uint8Array>) => void;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Abort the current stream.
|
|
73
|
+
*/
|
|
74
|
+
abort: () => void;
|
|
75
|
+
|
|
76
|
+
/** Reset state (data, error, isStreaming) */
|
|
77
|
+
reset: () => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ============================================================================
|
|
81
|
+
// Helpers
|
|
82
|
+
// ============================================================================
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Yield to the macrotask queue, allowing the browser to paint and
|
|
86
|
+
* process user input. This breaks the microtask starvation cycle
|
|
87
|
+
* caused by tight `await reader.read()` + `setState()` loops.
|
|
88
|
+
*/
|
|
89
|
+
function yieldToMacrotask(): Promise<void> {
|
|
90
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// Hook
|
|
95
|
+
// ============================================================================
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* React hook for reading SSE / ReadableStream data inside island components
|
|
99
|
+
* without blocking the main thread.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```tsx
|
|
103
|
+
* import { useSSE } from "@mandujs/core/client";
|
|
104
|
+
*
|
|
105
|
+
* function ChatIsland() {
|
|
106
|
+
* const { data, isStreaming, start } = useSSE({
|
|
107
|
+
* onComplete: (text) => console.log("Done:", text),
|
|
108
|
+
* });
|
|
109
|
+
*
|
|
110
|
+
* const sendMessage = async () => {
|
|
111
|
+
* const res = await fetch("/api/chat", { method: "POST", body: ... });
|
|
112
|
+
* start(res);
|
|
113
|
+
* };
|
|
114
|
+
*
|
|
115
|
+
* return (
|
|
116
|
+
* <div>
|
|
117
|
+
* <p>{data}</p>
|
|
118
|
+
* {isStreaming && <span>streaming...</span>}
|
|
119
|
+
* <button onClick={sendMessage} disabled={isStreaming}>Send</button>
|
|
120
|
+
* </div>
|
|
121
|
+
* );
|
|
122
|
+
* }
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export function useSSE(options: UseSSEOptions = {}): UseSSEReturn {
|
|
126
|
+
const {
|
|
127
|
+
onChunk = (acc: string, chunk: string) => acc + chunk,
|
|
128
|
+
onComplete,
|
|
129
|
+
onError,
|
|
130
|
+
throttleMs = 0,
|
|
131
|
+
} = options;
|
|
132
|
+
|
|
133
|
+
const [data, setData] = useState("");
|
|
134
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
135
|
+
const [error, setError] = useState<Error | null>(null);
|
|
136
|
+
|
|
137
|
+
// Refs for the current stream session (allow abort & prevent stale closures)
|
|
138
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
139
|
+
const accumulatorRef = useRef("");
|
|
140
|
+
const pendingChunksRef = useRef("");
|
|
141
|
+
const flushScheduledRef = useRef(false);
|
|
142
|
+
const optionsRef = useRef({ onChunk, onComplete, onError, throttleMs });
|
|
143
|
+
|
|
144
|
+
// Keep options ref in sync
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
optionsRef.current = { onChunk, onComplete, onError, throttleMs };
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Cleanup on unmount
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
return () => {
|
|
152
|
+
if (abortRef.current) {
|
|
153
|
+
abortRef.current.abort();
|
|
154
|
+
abortRef.current = null;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Flush pending chunks as a single batched state update.
|
|
161
|
+
* Uses setTimeout(0) to yield to the macrotask queue first.
|
|
162
|
+
*/
|
|
163
|
+
const flushPending = useCallback(() => {
|
|
164
|
+
if (!flushScheduledRef.current) return;
|
|
165
|
+
|
|
166
|
+
const pending = pendingChunksRef.current;
|
|
167
|
+
if (pending) {
|
|
168
|
+
const { onChunk: chunkFn } = optionsRef.current;
|
|
169
|
+
accumulatorRef.current = chunkFn(accumulatorRef.current, pending);
|
|
170
|
+
pendingChunksRef.current = "";
|
|
171
|
+
setData(accumulatorRef.current);
|
|
172
|
+
}
|
|
173
|
+
flushScheduledRef.current = false;
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Schedule a flush via setTimeout to yield to macrotask queue.
|
|
178
|
+
*/
|
|
179
|
+
const scheduleFlush = useCallback(() => {
|
|
180
|
+
if (!flushScheduledRef.current) {
|
|
181
|
+
flushScheduledRef.current = true;
|
|
182
|
+
setTimeout(flushPending, optionsRef.current.throttleMs);
|
|
183
|
+
}
|
|
184
|
+
}, [flushPending]);
|
|
185
|
+
|
|
186
|
+
const start = useCallback(
|
|
187
|
+
(source: Response | ReadableStream<Uint8Array>) => {
|
|
188
|
+
// Abort any existing stream
|
|
189
|
+
if (abortRef.current) {
|
|
190
|
+
abortRef.current.abort();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
abortRef.current = controller;
|
|
195
|
+
accumulatorRef.current = "";
|
|
196
|
+
pendingChunksRef.current = "";
|
|
197
|
+
flushScheduledRef.current = false;
|
|
198
|
+
|
|
199
|
+
setData("");
|
|
200
|
+
setError(null);
|
|
201
|
+
setIsStreaming(true);
|
|
202
|
+
|
|
203
|
+
const body =
|
|
204
|
+
source instanceof Response ? source.body : source;
|
|
205
|
+
|
|
206
|
+
if (!body) {
|
|
207
|
+
setIsStreaming(false);
|
|
208
|
+
setError(new Error("Response has no body"));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Start reading in a properly-yielding async loop
|
|
213
|
+
void (async () => {
|
|
214
|
+
const reader = body.getReader();
|
|
215
|
+
const decoder = new TextDecoder();
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
while (true) {
|
|
219
|
+
// Check for abort
|
|
220
|
+
if (controller.signal.aborted) {
|
|
221
|
+
await reader.cancel();
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const { done, value } = await reader.read();
|
|
226
|
+
if (done) break;
|
|
227
|
+
|
|
228
|
+
// Check for abort again (may have been aborted during read)
|
|
229
|
+
if (controller.signal.aborted) {
|
|
230
|
+
await reader.cancel();
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const text = decoder.decode(value, { stream: true });
|
|
235
|
+
if (text) {
|
|
236
|
+
// Accumulate chunks and schedule a batched flush
|
|
237
|
+
pendingChunksRef.current += text;
|
|
238
|
+
scheduleFlush();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// CRITICAL: Yield to the macrotask queue to prevent microtask
|
|
242
|
+
// starvation. Without this, the tight loop of
|
|
243
|
+
// `await reader.read() -> setState -> React microtask -> next read`
|
|
244
|
+
// starves the browser's macrotask queue, freezing the UI.
|
|
245
|
+
await yieldToMacrotask();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Final flush for any remaining chunks
|
|
249
|
+
if (pendingChunksRef.current) {
|
|
250
|
+
flushScheduledRef.current = true;
|
|
251
|
+
flushPending();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!controller.signal.aborted) {
|
|
255
|
+
setIsStreaming(false);
|
|
256
|
+
const opts = optionsRef.current;
|
|
257
|
+
if (opts.onComplete) {
|
|
258
|
+
opts.onComplete(accumulatorRef.current);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
} catch (err) {
|
|
262
|
+
if (
|
|
263
|
+
!controller.signal.aborted &&
|
|
264
|
+
!(err instanceof DOMException && err.name === "AbortError")
|
|
265
|
+
) {
|
|
266
|
+
const error =
|
|
267
|
+
err instanceof Error ? err : new Error(String(err));
|
|
268
|
+
setError(error);
|
|
269
|
+
setIsStreaming(false);
|
|
270
|
+
const opts = optionsRef.current;
|
|
271
|
+
if (opts.onError) {
|
|
272
|
+
opts.onError(error);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
})();
|
|
277
|
+
},
|
|
278
|
+
[scheduleFlush, flushPending]
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const abort = useCallback(() => {
|
|
282
|
+
if (abortRef.current) {
|
|
283
|
+
abortRef.current.abort();
|
|
284
|
+
abortRef.current = null;
|
|
285
|
+
}
|
|
286
|
+
setIsStreaming(false);
|
|
287
|
+
}, []);
|
|
288
|
+
|
|
289
|
+
const reset = useCallback(() => {
|
|
290
|
+
abort();
|
|
291
|
+
setData("");
|
|
292
|
+
setError(null);
|
|
293
|
+
accumulatorRef.current = "";
|
|
294
|
+
pendingChunksRef.current = "";
|
|
295
|
+
flushScheduledRef.current = false;
|
|
296
|
+
}, [abort]);
|
|
297
|
+
|
|
298
|
+
return { data, isStreaming, error, start, abort, reset };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ============================================================================
|
|
302
|
+
// Low-level utility (for non-hook usage)
|
|
303
|
+
// ============================================================================
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Read a ReadableStream with macrotask yielding, suitable for use
|
|
307
|
+
* outside of React components (e.g., in plain event handlers).
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* ```ts
|
|
311
|
+
* import { readStreamWithYield } from "@mandujs/core/client";
|
|
312
|
+
*
|
|
313
|
+
* const response = await fetch("/api/stream");
|
|
314
|
+
* await readStreamWithYield(response.body!, {
|
|
315
|
+
* onChunk: (text) => {
|
|
316
|
+
* // update DOM directly
|
|
317
|
+
* el.textContent += text;
|
|
318
|
+
* },
|
|
319
|
+
* onDone: () => console.log("done"),
|
|
320
|
+
* });
|
|
321
|
+
* ```
|
|
322
|
+
*/
|
|
323
|
+
export interface ReadStreamOptions {
|
|
324
|
+
/** Called for each decoded text chunk */
|
|
325
|
+
onChunk: (text: string) => void;
|
|
326
|
+
/** Called when the stream is complete */
|
|
327
|
+
onDone?: () => void;
|
|
328
|
+
/** Called on error */
|
|
329
|
+
onError?: (error: Error) => void;
|
|
330
|
+
/** AbortSignal to cancel reading */
|
|
331
|
+
signal?: AbortSignal;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function readStreamWithYield(
|
|
335
|
+
stream: ReadableStream<Uint8Array>,
|
|
336
|
+
options: ReadStreamOptions
|
|
337
|
+
): Promise<void> {
|
|
338
|
+
const { onChunk, onDone, onError, signal } = options;
|
|
339
|
+
const reader = stream.getReader();
|
|
340
|
+
const decoder = new TextDecoder();
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
while (true) {
|
|
344
|
+
if (signal?.aborted) {
|
|
345
|
+
await reader.cancel();
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const { done, value } = await reader.read();
|
|
350
|
+
if (done) break;
|
|
351
|
+
|
|
352
|
+
if (signal?.aborted) {
|
|
353
|
+
await reader.cancel();
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const text = decoder.decode(value, { stream: true });
|
|
358
|
+
if (text) {
|
|
359
|
+
onChunk(text);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Yield to macrotask queue
|
|
363
|
+
await yieldToMacrotask();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!signal?.aborted) {
|
|
367
|
+
onDone?.();
|
|
368
|
+
}
|
|
369
|
+
} catch (err) {
|
|
370
|
+
if (
|
|
371
|
+
!signal?.aborted &&
|
|
372
|
+
!(err instanceof DOMException && err.name === "AbortError")
|
|
373
|
+
) {
|
|
374
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
375
|
+
onError?.(error);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|