gemi 0.128.0 → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/{chunk-0mp9tck6.js → chunk-0ryswty9.js} +20 -20
  2. package/dist/{chunk-0mp9tck6.js.map → chunk-0ryswty9.js.map} +5 -4
  3. package/dist/{chunk-df0mekds.js → chunk-13agap7z.js} +1 -1
  4. package/dist/{chunk-qz0da20r.js → chunk-7ynnce4b.js} +1 -1
  5. package/dist/{chunk-wzfvwvz0.js → chunk-jj1ppnnk.js} +2 -2
  6. package/dist/{chunk-wzfvwvz0.js.map → chunk-jj1ppnnk.js.map} +1 -1
  7. package/dist/{chunk-tchte4dv.js → chunk-xtqhagxz.js} +2 -2
  8. package/dist/{chunk-tchte4dv.js.map → chunk-xtqhagxz.js.map} +1 -1
  9. package/dist/chunks/MutationError-DuKqBW8o.js +199 -0
  10. package/dist/chunks/MutationError-DuKqBW8o.js.map +1 -0
  11. package/dist/chunks/RealtimeClient-DsWbFRC7.js +553 -0
  12. package/dist/chunks/RealtimeClient-DsWbFRC7.js.map +1 -0
  13. package/dist/chunks/{ThemeProvider-BIrCSrFY.js → ThemeProvider-gWqXjGh-.js} +173 -578
  14. package/dist/chunks/ThemeProvider-gWqXjGh-.js.map +1 -0
  15. package/dist/chunks/channelKey-CIhHpX6x.js +10 -0
  16. package/dist/chunks/channelKey-CIhHpX6x.js.map +1 -0
  17. package/dist/chunks/fetchFileRequest-BSrgYnAy.js +57 -0
  18. package/dist/chunks/fetchFileRequest-BSrgYnAy.js.map +1 -0
  19. package/dist/chunks/live-CUdMhXHL.js +88 -0
  20. package/dist/chunks/live-CUdMhXHL.js.map +1 -0
  21. package/dist/chunks/mutationExtras-CtY-X9m7.js +115 -0
  22. package/dist/chunks/mutationExtras-CtY-X9m7.js.map +1 -0
  23. package/dist/chunks/subscriptionStream-CULcRbx0.js +166 -0
  24. package/dist/chunks/subscriptionStream-CULcRbx0.js.map +1 -0
  25. package/dist/client/MutationError.d.ts +7 -9
  26. package/dist/client/MutationError.d.ts.map +1 -1
  27. package/dist/client/QueryResource.d.ts +3 -11
  28. package/dist/client/QueryResource.d.ts.map +1 -1
  29. package/dist/client/fetchFile.d.ts +4 -7
  30. package/dist/client/fetchFile.d.ts.map +1 -1
  31. package/dist/client/fetchFileRequest.d.ts +14 -0
  32. package/dist/client/fetchFileRequest.d.ts.map +1 -0
  33. package/dist/client/index.d.ts +1 -1
  34. package/dist/client/index.d.ts.map +1 -1
  35. package/dist/client/index.js +201 -664
  36. package/dist/client/index.js.map +1 -1
  37. package/dist/client/init.d.ts +1 -1
  38. package/dist/client/init.d.ts.map +1 -1
  39. package/dist/client/lazyModule.d.ts +32 -0
  40. package/dist/client/lazyModule.d.ts.map +1 -0
  41. package/dist/client/mutationErrorHelpers.d.ts +11 -0
  42. package/dist/client/mutationErrorHelpers.d.ts.map +1 -0
  43. package/dist/client/mutationExtras.d.ts +3 -0
  44. package/dist/client/mutationExtras.d.ts.map +1 -0
  45. package/dist/client/mutationModules.d.ts +17 -0
  46. package/dist/client/mutationModules.d.ts.map +1 -0
  47. package/dist/client/queryUpdates.d.ts +22 -0
  48. package/dist/client/queryUpdates.d.ts.map +1 -0
  49. package/dist/client/realtime/FakeSocket.d.ts +1 -1
  50. package/dist/client/realtime/FakeSocket.d.ts.map +1 -1
  51. package/dist/client/realtime/RealtimeClient.d.ts +7 -18
  52. package/dist/client/realtime/RealtimeClient.d.ts.map +1 -1
  53. package/dist/client/realtime/channelKey.d.ts +4 -0
  54. package/dist/client/realtime/channelKey.d.ts.map +1 -0
  55. package/dist/client/realtime/live.d.ts +31 -0
  56. package/dist/client/realtime/live.d.ts.map +1 -0
  57. package/dist/client/realtime/loader.d.ts +56 -0
  58. package/dist/client/realtime/loader.d.ts.map +1 -0
  59. package/dist/client/realtime/useChannelSubscription.d.ts +1 -1
  60. package/dist/client/realtime/useChannelSubscription.d.ts.map +1 -1
  61. package/dist/client/subscriptionStream.d.ts +36 -0
  62. package/dist/client/subscriptionStream.d.ts.map +1 -0
  63. package/dist/client/useChannel.d.ts +1 -13
  64. package/dist/client/useChannel.d.ts.map +1 -1
  65. package/dist/client/useMutation.d.ts +1 -1
  66. package/dist/client/useMutation.d.ts.map +1 -1
  67. package/dist/client/useQuery.d.ts +1 -1
  68. package/dist/client/useQuery.d.ts.map +1 -1
  69. package/dist/client/useSetQueryData.d.ts +4 -17
  70. package/dist/client/useSetQueryData.d.ts.map +1 -1
  71. package/dist/client/useSubscription.d.ts +5 -10
  72. package/dist/client/useSubscription.d.ts.map +1 -1
  73. package/dist/facades/index.js +1 -1
  74. package/dist/http/index.js +1 -1
  75. package/dist/i18n/index.js +1 -1
  76. package/dist/kernel/index.js +1 -1
  77. package/dist/server/index.js +1 -1
  78. package/dist/services/broadcast/protocol.d.ts +1 -2
  79. package/dist/services/broadcast/protocol.d.ts.map +1 -1
  80. package/dist/services/broadcast/socketHeader.d.ts +9 -0
  81. package/dist/services/broadcast/socketHeader.d.ts.map +1 -0
  82. package/dist/services/index.js +2 -2
  83. package/dist/services/index.js.map +1 -1
  84. package/dist/testing/Page.d.ts.map +1 -1
  85. package/dist/testing/index.js +4 -1
  86. package/dist/testing/index.js.map +1 -1
  87. package/package.json +1 -1
  88. package/dist/chunks/ThemeProvider-BIrCSrFY.js.map +0 -1
  89. /package/dist/{chunk-df0mekds.js.map → chunk-13agap7z.js.map} +0 -0
  90. /package/dist/{chunk-qz0da20r.js.map → chunk-7ynnce4b.js.map} +0 -0
@@ -1,5 +1,6 @@
1
- import { $ as retryDelayFor, A as useLocation, B as getRealtimeClient, C as useRoute, D as I18nProvider, E as I18nContext, G as QueryConfigContext, H as SOCKET_ID_HEADER, I as RealtimeContext, J as USER_QUERY_CONFIG_KEYS, K as QueryManagerContext, L as channelKey, M as isAbsoluteUrl, O as ServerDataContext, P as Action, Q as parseRetryAfter, R as configureRealtime, S as routeDataUrl, T as useNavigate, U as toVariantKey, V as reconnectRealtime, W as applyParams$1, X as DEFAULT_STALE_TIME, Y as pickDefined, Z as isRetryableQueryError, _ as loadDictionary, a as useRouteTransition, b as setActiveLocale, c as ComponentsContext, d as loadViewModule, et as shouldRetryQuery, f as subscribeViewModules, g as recoverFromChunkLoadError, h as isChunkLoadError, i as RouteTransitionProvider, j as domainUrl, k as ServerDataProvider, l as ComponentsProvider, m as configureChunkLoadRecovery, n as useTheme, nt as Subject, o as ClientRouterContext, p as CHUNK_RELOAD_MARKER, q as QueryManagerProvider, r as m, s as ClientRouterProvider, t as ThemeProvider, tt as QueryError, u as initialViewModulesReady, v as loadDictionaryForRender, w as useSearchParams, x as readRoutePayload, y as registerDictionary, z as currentSocketId } from "../chunks/ThemeProvider-BIrCSrFY.js";
1
+ import { $ as DEFAULT_STALE_TIME, A as ServerDataContext, B as followLive, C as readRoutePayload, D as useNavigate, E as useSearchParams, G as loadedModule, H as realtime, I as Action, J as QueryConfigContext, K as toVariantKey, M as useLocation, N as domainUrl, O as I18nContext, P as isAbsoluteUrl, Q as pickDefined, R as RealtimeContext, S as setActiveLocale, T as useRoute, U as reconnectRealtime, W as loadLazy, X as QueryManagerProvider, Y as QueryManagerContext, Z as USER_QUERY_CONFIG_KEYS, _ as isChunkLoadError, a as useRouteTransition, at as Subject, b as loadDictionaryForRender, c as ClientRouterContext, d as ComponentsProvider, et as isRetryableQueryError, f as initialViewModulesReady, g as configureChunkLoadRecovery, h as CHUNK_RELOAD_MARKER, i as RouteTransitionProvider, it as QueryError, j as ServerDataProvider, k as I18nProvider, l as ClientRouterProvider, m as subscribeViewModules, n as useTheme, nt as retryDelayFor, o as useChannelSubscription, p as loadViewModule, q as applyParams$1, r as m, rt as shouldRetryQuery, s as useCoalesced, t as ThemeProvider, u as ComponentsContext, v as recoverFromChunkLoadError, w as routeDataUrl, x as registerDictionary, y as loadDictionary, z as configureRealtime } from "../chunks/ThemeProvider-gWqXjGh-.js";
2
2
  import { n as RouteStateContext, r as RouteStateProvider, t as useParams } from "../chunks/useParams-BN3XXfmG.js";
3
+ import { a as isNetworkError, c as isPermissionError, d as isValidationError, f as mutationErrorField, h as mutationErrorList, i as isHttpError, l as isRateLimitError, m as mutationErrorKind, n as isCsrfError, o as isNotFoundError, p as mutationErrorFromBody, r as isFormError, s as isObject, t as isAuthenticationError, u as isServerError } from "../chunks/MutationError-DuKqBW8o.js";
3
4
  import { Fragment, StrictMode, Suspense, createContext, createElement, isValidElement, lazy, memo, use, useCallback, useContext, useDeferredValue, useEffect, useMemo, useRef, useState, useSyncExternalStore, useTransition } from "react";
4
5
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
6
  import { createRoot as createRoot$1, hydrateRoot } from "react-dom/client";
@@ -27,180 +28,6 @@ function isPlainObject(value) {
27
28
  return value !== null && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
28
29
  }
29
30
  //#endregion
30
- //#region client/realtime/useChannelSubscription.ts
31
- var CLOSED = {
32
- status: "closed",
33
- code: null
34
- };
35
- var IDLE = {
36
- status: "idle",
37
- code: null
38
- };
39
- /**
40
- * The one subscription primitive the channel hooks share. Subscribes only in
41
- * an effect, so a server render, a static page or an island that never
42
- * mounts opens no socket; on the server it reports `closed`. Handlers may
43
- * change between renders without resubscribing.
44
- */
45
- function useChannelSubscription(pattern, params, handlers, enabled) {
46
- const active = enabled && pattern !== null;
47
- const [state, setState] = useState(active ? CLOSED : IDLE);
48
- const provided = useContext(RealtimeContext);
49
- const handlersRef = useRef(handlers);
50
- handlersRef.current = handlers;
51
- const paramsRef = useRef(params);
52
- paramsRef.current = params;
53
- useEffect(() => {
54
- if (!active || pattern === null) {
55
- setState(IDLE);
56
- return;
57
- }
58
- const socket = provided ?? getRealtimeClient();
59
- if (!socket) return;
60
- const release = socket.subscribe(pattern, paramsRef.current, {
61
- onEvent: (event, data) => handlersRef.current.onEvent?.(event, data),
62
- onResync: () => handlersRef.current.onResync?.(),
63
- onStatus: (status, code) => setState((previous) => previous.status === status && previous.code === code ? previous : {
64
- status,
65
- code
66
- })
67
- });
68
- return () => {
69
- release();
70
- setState(CLOSED);
71
- };
72
- }, [
73
- provided,
74
- pattern === null ? null : channelKey(pattern, params),
75
- active
76
- ]);
77
- if (typeof window === "undefined") return active ? CLOSED : IDLE;
78
- return state;
79
- }
80
- /**
81
- * `fn`, run at most once per `ms`: the first call starts the window, and the
82
- * calls inside it fold into the one run at its end. Stable across renders;
83
- * a pending run is dropped on unmount.
84
- */
85
- function useCoalesced(fn, ms = 150) {
86
- const fnRef = useRef(fn);
87
- fnRef.current = fn;
88
- const timer = useRef(null);
89
- useEffect(() => () => {
90
- if (timer.current) clearTimeout(timer.current);
91
- timer.current = null;
92
- }, []);
93
- const coalesced = useRef(null);
94
- coalesced.current ??= () => {
95
- if (timer.current) return;
96
- timer.current = setTimeout(() => {
97
- timer.current = null;
98
- fnRef.current();
99
- }, ms);
100
- };
101
- return coalesced.current;
102
- }
103
- //#endregion
104
- //#region client/useChannel.ts
105
- var warnedWithoutResync = /* @__PURE__ */ new Set();
106
- /**
107
- * Subscribes to a broadcast channel while the component is mounted.
108
- *
109
- * ```tsx
110
- * const { status } = useChannel("site.:siteId", { params: { siteId } }, {
111
- * on: { changed: (d) => mutate({ path: "/pages/:pageId", params: { pageId } }) },
112
- * onResync: () => mutate({ path: "/pages/:pageId", params: { pageId } }),
113
- * });
114
- * ```
115
- *
116
- * Events are hints, delivered at most once: pair `on` with `onResync`, which
117
- * runs whenever some may have been missed. `useChannelInvalidate` does both
118
- * for the common case. On the server, and until the effect runs, `status` is
119
- * `closed`.
120
- */
121
- function useChannel(pattern, options = {}, config = {}) {
122
- const configRef = useRef(config);
123
- configRef.current = config;
124
- const hasOn = config.on !== void 0 && Object.keys(config.on).length > 0;
125
- const hasResync = config.onResync !== void 0;
126
- useEffect(() => {
127
- if (process.env.NODE_ENV === "production") return;
128
- if (!hasOn || hasResync || warnedWithoutResync.has(pattern)) return;
129
- warnedWithoutResync.add(pattern);
130
- console.warn(`[gemi] useChannel("${pattern}") has \`on\` handlers but no \`onResync\`. Broadcast events are hints delivered at most once: after a reconnect or a gap the events in between are gone, so refetch in \`onResync\` (or use useChannelInvalidate).`);
131
- }, [
132
- pattern,
133
- hasOn,
134
- hasResync
135
- ]);
136
- return useChannelSubscription(pattern, options.params ?? {}, {
137
- onEvent: (event, data) => {
138
- const handler = configRef.current.on?.[event];
139
- handler?.(data);
140
- },
141
- onResync: () => configRef.current.onResync?.()
142
- }, config.enabled !== false);
143
- }
144
- /**
145
- * Refetches `paths` on any event on the channel and on every resync. Only
146
- * the variants being rendered refetch now; the rest are marked stale. Events
147
- * are coalesced: a burst within 150 ms refetches once.
148
- *
149
- * ```tsx
150
- * useChannelInvalidate("page.:pageId", { pageId }, ["/pages/:pageId", "/pages/:pageId/pictures"]);
151
- * ```
152
- */
153
- function useChannelInvalidate(pattern, params, paths, config = {}) {
154
- const { getResource } = useContext(QueryManagerContext);
155
- const routeParams = useParams();
156
- const channelParams = params ?? {};
157
- const latest = useRef({
158
- paths,
159
- routeParams,
160
- channelParams,
161
- getResource
162
- });
163
- latest.current = {
164
- paths,
165
- routeParams,
166
- channelParams,
167
- getResource
168
- };
169
- const invalidate = () => {
170
- const { paths, routeParams, channelParams, getResource } = latest.current;
171
- for (const entry of paths) {
172
- const target = typeof entry === "string" ? { path: entry } : entry;
173
- const resource = getResource(applyParams$1(target.path, {
174
- ...routeParams,
175
- ...channelParams,
176
- ...target.params
177
- }));
178
- const keys = target.search ? [toVariantKey(target.search)] : resource.variantKeys();
179
- for (const key of keys) resource.invalidate(key);
180
- }
181
- };
182
- return useChannelSubscription(pattern, channelParams, {
183
- onEvent: useCoalesced(invalidate),
184
- onResync: invalidate
185
- }, config.enabled !== false);
186
- }
187
- function resolveLiveChannel(live) {
188
- if (!live) return null;
189
- if (typeof live === "string") return {
190
- pattern: live,
191
- params: {}
192
- };
193
- if (Array.isArray(live)) return {
194
- pattern: live[0],
195
- params: live[1] ?? {}
196
- };
197
- const object = live;
198
- return {
199
- pattern: object.channel,
200
- params: object.params ?? {}
201
- };
202
- }
203
- //#endregion
204
31
  //#region client/useQuery.ts
205
32
  /**
206
33
  * A DOM event or React's synthetic one. Never a plain object, so a cached
@@ -275,18 +102,15 @@ function useFrameworkQuery(url, ...args) {
275
102
  const lazy = config.lazy;
276
103
  const configRef = useRef(config);
277
104
  configRef.current = config;
278
- const liveChannel = resolveLiveChannel(config.live);
105
+ const realtime = useContext(RealtimeContext);
106
+ const [liveOpen, setLiveOpen] = useState(false);
279
107
  const liveResourceRef = useRef(resource);
280
108
  liveResourceRef.current = resource;
281
- const refreshLive = useCallback(() => {
282
- const target = liveResourceRef.current;
283
- for (const key of target.variantKeys()) target.invalidate(key);
284
- }, []);
285
- const refreshLiveOnEvent = useCoalesced(refreshLive);
286
- const liveOpen = useChannelSubscription(liveChannel?.pattern ?? null, liveChannel?.params ?? {}, {
287
- onEvent: refreshLiveOnEvent,
288
- onResync: refreshLive
289
- }, liveChannel !== null).status === "open";
109
+ const liveKey = config.live ? JSON.stringify(config.live) : null;
110
+ useEffect(() => {
111
+ if (liveKey === null) return;
112
+ return followLive(realtime, JSON.parse(liveKey), () => liveResourceRef.current, setLiveOpen);
113
+ }, [realtime, liveKey]);
290
114
  const refreshIntervalRef = useRef(null);
291
115
  const fetchedRef = useRef(!lazy);
292
116
  const pollRef = useRef({
@@ -712,282 +536,36 @@ function useInfiniteQuery(url, options, config) {
712
536
  };
713
537
  }
714
538
  //#endregion
715
- //#region http/refusal.ts
716
- /**
717
- * The kind a status stands for, for a refusal that does not name one: an app's
718
- * own `RequestBreakerError`, or an older server's `{ message }`. Any other 4xx
719
- * is a `form_error`, a message about this request for `<FormError>` to show.
720
- */
721
- function refusalKindForStatus(status) {
722
- if (status === 401) return "authentication";
723
- if (status === 403) return "permission";
724
- if (status === 404) return "not_found";
725
- if (status === 416) return "range_not_satisfiable";
726
- if (status === 429) return "rate_limit";
727
- if (status >= 500) return "server_error";
728
- return "form_error";
729
- }
730
- //#endregion
731
- //#region client/MutationError.ts
732
- var LEGACY_MESSAGES = {
733
- "Authentication error": "authentication",
734
- "Not authorized": "authorization",
735
- "Insufficient permissions": "permission",
736
- "Invalid CSRF token": "csrf",
737
- "Internal Server Error": "server_error",
738
- Forbidden: "permission",
739
- "Not found": "not_found",
740
- "Rate limit exceeded": "rate_limit"
741
- };
742
- function isObject(error) {
743
- return typeof error === "object" && error !== null;
744
- }
745
- function legacyKind(message, status) {
746
- return LEGACY_MESSAGES[message] ?? (status === 401 ? "authorization" : refusalKindForStatus(status));
747
- }
539
+ //#region services/broadcast/socketHeader.ts
748
540
  /**
749
- * The `MutationError` a non-2xx response's parsed body stands for.
750
- *
751
- * gemi's own error bodies are `{ error }`, and the value under `error` is what
752
- * is handed over. An app's body without one —
753
- * `HttpResponse.json({ message: "Taken" }, { status: 409 })` — is handed over
754
- * whole. A body with no content (`null`) becomes a refusal naming the status.
755
- *
756
- * What is handed over carries `status`, and a `kind` when it has a `message`:
757
- * a bare string, which servers before 0.88 sent for most refusals, becomes
758
- * `{ kind, message, status }`, and so does a `{ message }` without a kind.
541
+ * The request header a client sends its socket id in, for `toOthers`.
759
542
  *
760
- * @internal
543
+ * A module of its own, re-exported by `./protocol`: every mutation in the
544
+ * browser reads it, and anything else in `./protocol` it pulled along would
545
+ * land in every route's entry chunk instead of the lazily loaded socket's.
761
546
  */
762
- function mutationErrorFromBody(body, status) {
763
- const error = isObject(body) && "error" in body && body.error != null ? body.error : body;
764
- if (error == null) return {
765
- kind: refusalKindForStatus(status),
766
- message: `Request failed with status ${status}`,
767
- status
768
- };
769
- if (typeof error === "string") return {
770
- kind: legacyKind(error, status),
771
- message: error,
772
- status
773
- };
774
- if (!isObject(error) || error instanceof Error) return error;
775
- const withStatus = {
776
- ...error,
777
- status: typeof error.status === "number" ? error.status : status
778
- };
779
- if (typeof error.kind !== "string" && typeof error.message === "string") return {
780
- ...withStatus,
781
- kind: refusalKindForStatus(withStatus.status)
782
- };
783
- return withStatus;
784
- }
785
- function kindOf(error) {
786
- if (typeof error === "string") return LEGACY_MESSAGES[error];
787
- if (!isObject(error) || error instanceof Error) return void 0;
788
- if (typeof error.kind === "string") return error.kind;
789
- if (typeof error.status === "number") return refusalKindForStatus(error.status);
790
- return typeof error.message === "string" ? LEGACY_MESSAGES[error.message] : void 0;
791
- }
792
- /** Field messages, as `<ValidationErrors>` renders them. */
793
- function isValidationError(error) {
794
- return isObject(error) && error.kind === "validation_error";
795
- }
796
- /** A form-level message, as `<FormError>` renders it. */
797
- function isFormError(error) {
798
- return isObject(error) && error.kind === "form_error";
799
- }
800
- /** No signed-in user: `AuthenticationError` (401). */
801
- function isAuthenticationError(error) {
802
- return kindOf(error) === "authentication";
803
- }
804
- /**
805
- * A signed-in user who may not do this: `InsufficientPermissionsError` (403),
806
- * `AuthorizationError` (401) or a policy denial (403), whatever message it was
807
- * thrown with.
808
- */
809
- function isPermissionError(error) {
810
- const kind = kindOf(error);
811
- return kind === "permission" || kind === "authorization";
812
- }
813
- /** The CSRF cookie is missing or stale (403). A reload fixes it. */
814
- function isCsrfError(error) {
815
- return kindOf(error) === "csrf";
816
- }
817
- /** A missing record, file or route (404). */
818
- function isNotFoundError(error) {
819
- return kindOf(error) === "not_found";
820
- }
821
- /**
822
- * `RateLimitMiddleware` or `RateLimiter.enforce` refused the request (429).
823
- * `retryAfter` (ms) says when to try again, when the server sent it.
824
- */
825
- function isRateLimitError(error) {
826
- return kindOf(error) === "rate_limit";
827
- }
828
- /**
829
- * The server failed (a 500, under `gemi dev` too), or answered something that
830
- * is not JSON.
831
- */
832
- function isServerError(error) {
833
- return kindOf(error) === "server_error" || error instanceof SyntaxError;
834
- }
835
- /** The request got no answer: offline, DNS, CORS, a dropped connection. */
836
- function isNetworkError(error) {
837
- return error instanceof TypeError;
838
- }
839
- /**
840
- * Which of the guards above `error` passes, for a `switch` over all of them.
841
- * `"unknown"` is everything else: a 416, an error body of the app's own shape.
842
- */
843
- function mutationErrorKind(error) {
844
- if (isValidationError(error)) return "validation";
845
- if (isFormError(error)) return "form";
846
- if (isAuthenticationError(error)) return "authentication";
847
- if (isPermissionError(error)) return "permission";
848
- if (isCsrfError(error)) return "csrf";
849
- if (isNotFoundError(error)) return "not_found";
850
- if (isRateLimitError(error)) return "rate_limit";
851
- if (isServerError(error)) return "server";
852
- if (isNetworkError(error)) return "network";
853
- return "unknown";
854
- }
855
- /**
856
- * An error answered with `status`: a route's typed error from
857
- * `HttpResponse.error(status, body)` / `httpError`, narrowed to the members
858
- * of `error`'s type with that status.
859
- *
860
- * ```ts
861
- * onError: (error) => {
862
- * if (isHttpError(error, 410)) toast(error.message);
863
- * }
864
- * ```
865
- *
866
- * At run time it checks the status alone, so gemi's own refusals with the
867
- * same status (a 404 `not_found`, a 429 `rate_limit`) pass it too; pick a
868
- * status gemi does not use for its own errors, or check `kind` after it.
869
- */
870
- function isHttpError(error, status) {
871
- return isObject(error) && !(error instanceof Error) && error.status === status;
872
- }
873
- var FALLBACK_MESSAGE = "Something went wrong. Try again.";
874
- function unwrapQueryError(error) {
875
- if (error instanceof Error && error.name === "QueryError" && "body" in error) {
876
- const { body, status } = error;
877
- return body == null ? error : mutationErrorFromBody(body, status);
878
- }
879
- return error;
880
- }
881
- /**
882
- * `error.list(fallback)` for any value: an error kept from a mutation, a
883
- * `useQuery` `QueryError` (its body is read), or anything else.
884
- */
885
- function mutationErrorList(error, fallback = FALLBACK_MESSAGE) {
886
- const value = unwrapQueryError(error);
887
- if (isValidationError(value)) {
888
- const messages = Object.values(value.messages ?? {}).flat().filter((message) => typeof message === "string" && message !== "");
889
- return messages.length > 0 ? messages : [fallback];
890
- }
891
- if (isObject(value) && !(value instanceof Error) && value.kind !== "server_error" && typeof value.message === "string" && value.message !== "") return [value.message];
892
- return [fallback];
893
- }
894
- /**
895
- * `error.field(name)` for any value: a validation error's messages for
896
- * `name` (a `QueryError`'s body is read), `[]` for anything else.
897
- */
898
- function mutationErrorField(error, name) {
899
- const value = unwrapQueryError(error);
900
- if (!isValidationError(value)) return [];
901
- const messages = value.messages?.[name];
902
- return Array.isArray(messages) ? [...messages] : [];
903
- }
904
- /**
905
- * Puts `list` and `field` on an error the hooks hand over, as non-enumerable
906
- * properties, so `toEqual`, a spread and `JSON.stringify` see the same object
907
- * as before. A key the error already has is left alone; a primitive or a
908
- * frozen value is returned as it is.
909
- *
910
- * @internal
911
- */
912
- function withMutationErrorHelpers(error) {
913
- if (!isObject(error) || !Object.isExtensible(error)) return error;
914
- const helpers = {
915
- list: (fallback) => mutationErrorList(error, fallback),
916
- field: (name) => mutationErrorField(error, name)
917
- };
918
- for (const key of ["list", "field"]) {
919
- if (key in error) continue;
920
- Object.defineProperty(error, key, {
921
- value: helpers[key],
922
- configurable: true,
923
- writable: true,
924
- enumerable: false
925
- });
926
- }
927
- return error;
928
- }
547
+ var SOCKET_ID_HEADER = "x-gemi-socket";
929
548
  //#endregion
930
- //#region client/useSetQueryData.ts
549
+ //#region client/mutationModules.ts
931
550
  /**
932
- * Write into every variant `target` names. `next` is the value to write, or
933
- * a function of the cached value; a function skips a variant with nothing
934
- * cached. A value seeds a single variant that is not cached yet; a predicate
935
- * only ever reaches variants that are.
936
- */
937
- function writeQueryData(getResource, routeParams, target, next) {
938
- const { path, params = {}, search = {} } = target;
939
- const resource = getResource(applyParams$1(String(path), {
940
- ...routeParams,
941
- ...params
942
- }));
943
- const write = (variantKey) => {
944
- if ("value" in next) {
945
- resource.set(variantKey, next.value);
946
- return;
947
- }
948
- const state = resource.peek(variantKey);
949
- if (!state?.hasData) return;
950
- resource.set(variantKey, next.update(state.data));
951
- };
952
- if (typeof search === "function") {
953
- for (const variantKey of resource.variantKeys()) {
954
- if (!resource.peek(variantKey)?.hasData) continue;
955
- if (search(new URLSearchParams(variantKey))) write(variantKey);
956
- }
957
- return;
958
- }
959
- write(toVariantKey(search));
960
- }
961
- /**
962
- * Write a mutation's `updates` with its answer.
963
- */
964
- function applyQueryUpdates(getResource, routeParams, updates, answer) {
965
- for (const update of updates ?? []) {
966
- const { data } = update;
967
- writeQueryData(getResource, routeParams, update, data ? { update: (current) => data(answer, current) } : { value: answer });
968
- }
969
- }
970
- /**
971
- * Returns a function that writes into any query's cache by path, with no
972
- * refetch: for data the server already answered with. `useMutate` is the
973
- * optimistic counterpart, which refetches to reconcile.
551
+ * `./mutationExtras`, which a page only needs once it mutates: loaded when the
552
+ * first mutation hook, or `useSetQueryData()`, mounts (#915), so no route's
553
+ * entry chunk carries it. By the time a request settles it is normally there;
554
+ * one that settles first waits for it.
974
555
  *
975
- * ```ts
976
- * const setQueryData = useSetQueryData();
977
- * setQueryData({ path: "/pages/:pageId/assets", params: { pageId } }, assets);
978
- * setQueryData({ path: "/todos", search: () => true }, (todos) => todos.slice(1));
979
- * ```
980
- *
981
- * A value replaces the variant's data, and seeds it when nothing is cached
982
- * yet. A function updates what is cached and skips a variant with nothing
983
- * cached. A predicate `search` reaches every cached variant it accepts.
556
+ * Keep this module out of `gemi/testing`'s imports. Shared with that entry, it
557
+ * moves into the chunk both entries import, and there an app's bundler can
558
+ * emit `./mutationExtras` even for an app that never mutates, which splits the
559
+ * app's entry chunk ("needs no chunk besides the entry to start" in
560
+ * `packaging.test.ts`).
984
561
  */
985
- function useSetQueryData() {
986
- const { getResource } = useContext(QueryManagerContext);
987
- const routeParams = useParams();
988
- return useCallback(function setQueryData(target, next) {
989
- writeQueryData(getResource, routeParams, target, typeof next === "function" ? { update: next } : { value: next });
990
- }, [getResource, routeParams]);
562
+ var mutationExtras = {
563
+ current: null,
564
+ load: () => import("../chunks/mutationExtras-CtY-X9m7.js")
565
+ };
566
+ /** Starts loading `./mutationExtras`: the hooks' mount effect. */
567
+ function preloadMutationExtras() {
568
+ loadLazy(mutationExtras).catch(() => {});
991
569
  }
992
570
  //#endregion
993
571
  //#region client/useMutation.ts
@@ -1022,6 +600,7 @@ function useMutation(method, url, ...args) {
1022
600
  ...config
1023
601
  };
1024
602
  const parallel = options.concurrency === "parallel";
603
+ useEffect(preloadMutationExtras, []);
1025
604
  async function trigger(input, call = {}) {
1026
605
  const controller = new AbortController();
1027
606
  abortController.current = controller;
@@ -1064,24 +643,26 @@ function useMutation(method, url, ...args) {
1064
643
  if (input instanceof FormData) body = input;
1065
644
  else if (typeof input === "undefined") body = formData.current;
1066
645
  else if (input) body = JSON.stringify(input);
646
+ const socketId = realtime.client?.socketId;
1067
647
  try {
1068
648
  const response = await fetch(`/api${finalUrl}`, {
1069
649
  method,
1070
650
  headers: {
1071
651
  ...contentType,
1072
- ...socketIdHeader()
652
+ ...socketId ? { [SOCKET_ID_HEADER]: socketId } : {}
1073
653
  },
1074
654
  ...body ? { body } : {},
1075
655
  signal: controller.signal
1076
656
  });
1077
657
  const data = await response.json();
658
+ const error = response.ok ? null : await withErrorHelpers(mutationErrorFromBody(data, response.status));
659
+ const extras = response.ok && options.updates ? await loadOrNull() : null;
1078
660
  if (isSuperseded()) {
1079
661
  settle((prev) => prev, true);
1080
662
  return;
1081
663
  }
1082
664
  resetFormData();
1083
665
  if (!response.ok) {
1084
- const error = withMutationErrorHelpers(mutationErrorFromBody(data, response.status));
1085
666
  settle((prev) => ({
1086
667
  data: prev.data,
1087
668
  error
@@ -1091,7 +672,7 @@ function useMutation(method, url, ...args) {
1091
672
  return;
1092
673
  }
1093
674
  clearPrefetchCache?.();
1094
- applyQueryUpdates(getResource, _params, options.updates, data);
675
+ extras?.applyQueryUpdates(getResource, _params, options.updates, data);
1095
676
  options.onSuccess(data);
1096
677
  call.onSuccess?.(data);
1097
678
  settle(() => ({
@@ -1099,7 +680,8 @@ function useMutation(method, url, ...args) {
1099
680
  error: null
1100
681
  }));
1101
682
  return data;
1102
- } catch (error) {
683
+ } catch (thrown) {
684
+ const error = await withErrorHelpers(thrown);
1103
685
  if (isSuperseded()) {
1104
686
  settle((prev) => prev, true);
1105
687
  return;
@@ -1109,12 +691,11 @@ function useMutation(method, url, ...args) {
1109
691
  settle((prev) => prev);
1110
692
  return;
1111
693
  }
1112
- const failure = withMutationErrorHelpers(error);
1113
- options.onError(failure);
1114
- call.onError?.(failure);
694
+ options.onError(error);
695
+ call.onError?.(error);
1115
696
  settle((prev) => ({
1116
697
  data: parallel ? prev.data : null,
1117
- error: failure
698
+ error
1118
699
  }));
1119
700
  }
1120
701
  }
@@ -1162,6 +743,7 @@ function useUpload(url, ...args) {
1162
743
  const { getResource } = useContext(QueryManagerContext);
1163
744
  const abortRef = useRef(null);
1164
745
  const [inputs = {}, options = defaultOptions] = args ?? [];
746
+ useEffect(preloadMutationExtras, []);
1165
747
  const cancel = () => {
1166
748
  const abort = abortRef.current;
1167
749
  if (!abort) return;
@@ -1231,20 +813,22 @@ function useUpload(url, ...args) {
1231
813
  try {
1232
814
  error = mutationErrorFromBody(JSON.parse(outcome.body), outcome.status);
1233
815
  } catch {}
816
+ const failure = await withErrorHelpers(error);
1234
817
  setState("error");
1235
- options?.onError?.(withMutationErrorHelpers(error));
818
+ options?.onError?.(failure);
1236
819
  return;
1237
820
  }
1238
821
  const json = JSON.parse(outcome.body);
1239
822
  clearPrefetchCache?.();
1240
- applyQueryUpdates(getResource, _params, options?.updates, json);
823
+ if (options.updates) (await loadOrNull())?.applyQueryUpdates(getResource, _params, options.updates, json);
1241
824
  options?.onSuccess?.(json);
1242
825
  setState("done");
1243
826
  return json;
1244
827
  } catch (error) {
1245
828
  release();
829
+ const failure = await withErrorHelpers(error);
1246
830
  setState("error");
1247
- options?.onError?.(withMutationErrorHelpers(error));
831
+ options?.onError?.(failure);
1248
832
  return;
1249
833
  }
1250
834
  };
@@ -1255,13 +839,17 @@ function useUpload(url, ...args) {
1255
839
  cancel
1256
840
  };
1257
841
  }
1258
- /**
1259
- * `X-Gemi-Socket` with this tab's broadcast socket id, while one is open, so
1260
- * a controller's `Broadcast.toOthers(req)` skips the tab that made the change.
1261
- */
1262
- function socketIdHeader() {
1263
- const id = currentSocketId();
1264
- return id ? { [SOCKET_ID_HEADER]: id } : {};
842
+ function loadOrNull() {
843
+ return loadLazy(mutationExtras).catch(() => null);
844
+ }
845
+ async function withErrorHelpers(error) {
846
+ const extras = await loadOrNull();
847
+ if (extras) return extras.withMutationErrorHelpers(error);
848
+ if (isObject(error) && Object.isExtensible(error) && !("list" in error)) Object.defineProperties(error, {
849
+ list: { value: (fallback = "Something went wrong. Try again.") => [fallback] },
850
+ field: { value: () => [] }
851
+ });
852
+ return error;
1265
853
  }
1266
854
  //#endregion
1267
855
  //#region client/useMutate.ts
@@ -1309,6 +897,10 @@ function useMutate() {
1309
897
  }
1310
898
  //#endregion
1311
899
  //#region client/fetchFile.ts
900
+ var request = {
901
+ current: null,
902
+ load: () => import("../chunks/fetchFileRequest-BSrgYnAy.js")
903
+ };
1312
904
  /**
1313
905
  * Downloads a file route's body as a `Blob`.
1314
906
  *
@@ -1323,75 +915,57 @@ function useMutate() {
1323
915
  * `retryAfter`), the error `useQuery` throws, so `mutationErrorList(error)`
1324
916
  * reads its message. A network failure rejects with the browser's `TypeError`,
1325
917
  * an abort with its `AbortError`.
918
+ *
919
+ * The request code loads on the first call (#915), so a route that never
920
+ * downloads a file carries none of it; a failed load rejects as a network
921
+ * failure does.
1326
922
  */
1327
923
  async function fetchBlob(path, ...args) {
1328
- const response = await request(path, args[0]);
1329
- return {
1330
- blob: await response.blob(),
1331
- filename: filenameOf(response),
1332
- response
1333
- };
924
+ return (await loadLazy(request)).fetchFile(path, args[0], "blob");
1334
925
  }
1335
926
  /** `fetchBlob`, with the body read as text. */
1336
927
  async function fetchText(path, ...args) {
1337
- const response = await request(path, args[0]);
1338
- return {
1339
- text: await response.text(),
1340
- filename: filenameOf(response),
1341
- response
1342
- };
1343
- }
1344
- async function request(path, options = {}) {
1345
- const { params = {}, search, signal, init } = options;
1346
- const url = applyParams$1(path, params);
1347
- const query = searchString(search);
1348
- const response = await fetch(`/api${url}${query ? `?${query}` : ""}`, {
1349
- ...init,
1350
- method: "GET",
1351
- signal
1352
- });
1353
- if (!response.ok) {
1354
- const body = await response.json().catch(() => null);
1355
- throw new QueryError(url, query, response.status, body, parseRetryAfter(response.headers.get("retry-after")));
1356
- }
1357
- return response;
1358
- }
1359
- function searchString(search) {
1360
- if (!search) return "";
1361
- if (search instanceof URLSearchParams) return search.toString();
1362
- const params = new URLSearchParams();
1363
- for (const [key, value] of Object.entries(search)) if (value !== null && value !== void 0) params.append(key, String(value));
1364
- return params.toString();
928
+ return (await loadLazy(request)).fetchFile(path, args[0], "text");
1365
929
  }
930
+ //#endregion
931
+ //#region client/useSetQueryData.ts
1366
932
  /**
1367
- * The file name in a `Content-Disposition` header: the RFC 5987
1368
- * `filename*=UTF-8''...` form when present and decodable, else `filename`.
933
+ * Returns a function that writes into any query's cache by path, with no
934
+ * refetch: for data the server already answered with. `useMutate` is the
935
+ * optimistic counterpart, which refetches to reconcile.
936
+ *
937
+ * ```ts
938
+ * const setQueryData = useSetQueryData();
939
+ * setQueryData({ path: "/pages/:pageId/assets", params: { pageId } }, assets);
940
+ * setQueryData({ path: "/todos", search: () => true }, (todos) => todos.slice(1));
941
+ * ```
942
+ *
943
+ * A value replaces the variant's data, and seeds it when nothing is cached
944
+ * yet. A function updates what is cached and skips a variant with nothing
945
+ * cached. A predicate `search` reaches every cached variant it accepts.
1369
946
  *
1370
- * @internal Exported for tests.
947
+ * The code behind the writes is loaded when the hook mounts. A call made
948
+ * before it has arrived (during the first render, say) is written once it
949
+ * has, in order.
1371
950
  */
1372
- function filenameOf(response) {
1373
- const header = response.headers.get("content-disposition");
1374
- if (!header) return null;
1375
- const extended = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(header);
1376
- if (extended) try {
1377
- return decodeURIComponent(extended[2].trim());
1378
- } catch {}
1379
- const plain = /filename\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;]+))/i.exec(header);
1380
- if (!plain) return null;
1381
- return (plain[1] !== void 0 ? plain[1].replace(/\\(.)/g, "$1") : plain[2].trim()) || null;
951
+ function useSetQueryData() {
952
+ const { getResource } = useContext(QueryManagerContext);
953
+ const routeParams = useParams();
954
+ useEffect(preloadMutationExtras, []);
955
+ return useCallback(function setQueryData(target, next) {
956
+ const write = typeof next === "function" ? { update: next } : { value: next };
957
+ const writes = mutationExtras.current;
958
+ if (writes) {
959
+ writes.writeQueryData(getResource, routeParams, target, write);
960
+ return;
961
+ }
962
+ loadLazy(mutationExtras).then((loaded) => loaded.writeQueryData(getResource, routeParams, target, write)).catch(() => {});
963
+ }, [getResource, routeParams]);
1382
964
  }
1383
965
  //#endregion
1384
966
  //#region client/useSubscription.ts
1385
- var MIN_RETRY_MS = 1e3;
1386
- var MAX_RETRY_MS = 3e4;
1387
- /** The server answered in a way retrying will not change (401, 403, 404). */
1388
- var FatalStatus = class extends Error {
1389
- status;
1390
- constructor(status) {
1391
- super(`The subscription was refused with ${status}.`);
1392
- this.status = status;
1393
- }
1394
- };
967
+ /** `./subscriptionStream`, loaded on first use. Replaceable in tests. */
968
+ var streamModule = { load: () => import("../chunks/subscriptionStream-CULcRbx0.js") };
1395
969
  /**
1396
970
  * Follows a route that answers with `ChangeFeed.stream`, as long as the
1397
971
  * component is mounted: each change reaches `onChange`, in order, exactly
@@ -1442,105 +1016,27 @@ function useSubscription(url, options = {}, config = {}) {
1442
1016
  return;
1443
1017
  }
1444
1018
  let stopped = false;
1445
- let request = null;
1446
- let resume = null;
1447
- let retryMs = MIN_RETRY_MS;
1448
- const hidden = () => pauseWhenHidden && typeof document !== "undefined" && document.visibilityState === "hidden";
1449
- const onVisibilityChange = () => {
1450
- if (hidden()) request?.abort();
1451
- else resume?.();
1452
- };
1453
- if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisibilityChange);
1454
- const wait = (ms) => new Promise((resolve) => {
1455
- const timer = setTimeout(done, ms);
1456
- function done() {
1457
- clearTimeout(timer);
1458
- resume = null;
1459
- resolve();
1460
- }
1461
- resume = done;
1019
+ let stop = null;
1020
+ streamModule.load().then((module) => {
1021
+ const { follow } = loadedModule(module);
1022
+ if (stopped) return;
1023
+ stop = follow({
1024
+ endpoint,
1025
+ pauseWhenHidden,
1026
+ cursor,
1027
+ callbacks,
1028
+ setState
1029
+ });
1030
+ }).catch((error) => {
1031
+ if (!stopped) setState((s) => ({
1032
+ ...s,
1033
+ status: "closed",
1034
+ error
1035
+ }));
1462
1036
  });
1463
- (async () => {
1464
- while (!stopped) {
1465
- if (hidden()) {
1466
- setState((s) => ({
1467
- ...s,
1468
- status: "paused"
1469
- }));
1470
- await new Promise((resolve) => resume = resolve);
1471
- resume = null;
1472
- continue;
1473
- }
1474
- setState((s) => ({
1475
- ...s,
1476
- status: "connecting"
1477
- }));
1478
- const controller = request = new AbortController();
1479
- let delay = retryMs;
1480
- try {
1481
- const headers = { Accept: "text/event-stream" };
1482
- if (cursor.current) headers["Last-Event-ID"] = cursor.current;
1483
- const response = await fetch(endpoint, {
1484
- headers,
1485
- signal: controller.signal
1486
- });
1487
- if (!response.ok || !response.body) {
1488
- const status = response.status;
1489
- if (status >= 400 && status < 500 && status !== 408 && status !== 429) throw new FatalStatus(status);
1490
- const retryAfter = Number(response.headers.get("Retry-After"));
1491
- if (Number.isFinite(retryAfter) && retryAfter > 0) delay = retryAfter * 1e3;
1492
- throw new Error(`The subscription answered ${status}.`);
1493
- }
1494
- for await (const { id, message } of readEvents(response.body)) {
1495
- if (stopped) return;
1496
- if (id !== void 0) cursor.current = id;
1497
- if (message.type === "ready") {
1498
- retryMs = MIN_RETRY_MS;
1499
- delay = MIN_RETRY_MS;
1500
- setState({
1501
- status: "open",
1502
- cursor: cursor.current,
1503
- error: null
1504
- });
1505
- } else {
1506
- setState((s) => ({
1507
- ...s,
1508
- cursor: cursor.current
1509
- }));
1510
- if (message.type === "change") callbacks.current.onChange?.(message);
1511
- else if (message.type === "reset") callbacks.current.onReset?.(message);
1512
- }
1513
- }
1514
- } catch (error) {
1515
- if (stopped) return;
1516
- if (error instanceof FatalStatus) {
1517
- setState((s) => ({
1518
- ...s,
1519
- status: "closed",
1520
- error
1521
- }));
1522
- return;
1523
- }
1524
- if (controller.signal.aborted) continue;
1525
- setState((s) => ({
1526
- ...s,
1527
- error
1528
- }));
1529
- }
1530
- if (stopped || controller.signal.aborted) continue;
1531
- setState((s) => ({
1532
- ...s,
1533
- status: "connecting"
1534
- }));
1535
- await wait(delay);
1536
- retryMs = Math.min(retryMs * 2, MAX_RETRY_MS);
1537
- }
1538
- })();
1539
1037
  return () => {
1540
1038
  stopped = true;
1541
- request?.abort();
1542
- resume?.();
1543
- if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisibilityChange);
1039
+ stop?.();
1544
1040
  };
1545
1041
  }, [
1546
1042
  endpoint,
@@ -1550,48 +1046,89 @@ function useSubscription(url, options = {}, config = {}) {
1550
1046
  ]);
1551
1047
  return state;
1552
1048
  }
1049
+ //#endregion
1050
+ //#region client/useChannel.ts
1051
+ var warnedWithoutResync = /* @__PURE__ */ new Set();
1553
1052
  /**
1554
- * The events of an SSE body: `id` and the JSON `data`. Comments (keepalives)
1555
- * and events without data are skipped; an event whose data is not JSON is
1556
- * dropped rather than ending the stream.
1053
+ * Subscribes to a broadcast channel while the component is mounted.
1054
+ *
1055
+ * ```tsx
1056
+ * const { status } = useChannel("site.:siteId", { params: { siteId } }, {
1057
+ * on: { changed: (d) => mutate({ path: "/pages/:pageId", params: { pageId } }) },
1058
+ * onResync: () => mutate({ path: "/pages/:pageId", params: { pageId } }),
1059
+ * });
1060
+ * ```
1061
+ *
1062
+ * Events are hints, delivered at most once: pair `on` with `onResync`, which
1063
+ * runs whenever some may have been missed. `useChannelInvalidate` does both
1064
+ * for the common case. On the server, and until the effect runs, `status` is
1065
+ * `closed`.
1557
1066
  */
1558
- async function* readEvents(body) {
1559
- const reader = body.getReader();
1560
- const decoder = new TextDecoder();
1561
- let buffer = "";
1562
- try {
1563
- for (;;) {
1564
- const { done, value } = await reader.read();
1565
- if (done) return;
1566
- buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n?/g, "\n");
1567
- let boundary = buffer.indexOf("\n\n");
1568
- while (boundary !== -1) {
1569
- const block = buffer.slice(0, boundary);
1570
- buffer = buffer.slice(boundary + 2);
1571
- boundary = buffer.indexOf("\n\n");
1572
- let id;
1573
- const data = [];
1574
- for (const line of block.split("\n")) {
1575
- if (line === "" || line.startsWith(":")) continue;
1576
- const colon = line.indexOf(":");
1577
- const field = colon === -1 ? line : line.slice(0, colon);
1578
- let text = colon === -1 ? "" : line.slice(colon + 1);
1579
- if (text.startsWith(" ")) text = text.slice(1);
1580
- if (field === "id") id = text;
1581
- else if (field === "data") data.push(text);
1582
- }
1583
- if (data.length === 0) continue;
1584
- try {
1585
- yield {
1586
- id,
1587
- message: JSON.parse(data.join("\n"))
1588
- };
1589
- } catch {}
1590
- }
1067
+ function useChannel(pattern, options = {}, config = {}) {
1068
+ const configRef = useRef(config);
1069
+ configRef.current = config;
1070
+ const hasOn = config.on !== void 0 && Object.keys(config.on).length > 0;
1071
+ const hasResync = config.onResync !== void 0;
1072
+ useEffect(() => {
1073
+ if (process.env.NODE_ENV === "production") return;
1074
+ if (!hasOn || hasResync || warnedWithoutResync.has(pattern)) return;
1075
+ warnedWithoutResync.add(pattern);
1076
+ console.warn(`[gemi] useChannel("${pattern}") has \`on\` handlers but no \`onResync\`. Broadcast events are hints delivered at most once: after a reconnect or a gap the events in between are gone, so refetch in \`onResync\` (or use useChannelInvalidate).`);
1077
+ }, [
1078
+ pattern,
1079
+ hasOn,
1080
+ hasResync
1081
+ ]);
1082
+ return useChannelSubscription(pattern, options.params ?? {}, {
1083
+ onEvent: (event, data) => {
1084
+ const handler = configRef.current.on?.[event];
1085
+ handler?.(data);
1086
+ },
1087
+ onResync: () => configRef.current.onResync?.()
1088
+ }, config.enabled !== false);
1089
+ }
1090
+ /**
1091
+ * Refetches `paths` on any event on the channel and on every resync. Only
1092
+ * the variants being rendered refetch now; the rest are marked stale. Events
1093
+ * are coalesced: a burst within 150 ms refetches once.
1094
+ *
1095
+ * ```tsx
1096
+ * useChannelInvalidate("page.:pageId", { pageId }, ["/pages/:pageId", "/pages/:pageId/pictures"]);
1097
+ * ```
1098
+ */
1099
+ function useChannelInvalidate(pattern, params, paths, config = {}) {
1100
+ const { getResource } = useContext(QueryManagerContext);
1101
+ const routeParams = useParams();
1102
+ const channelParams = params ?? {};
1103
+ const latest = useRef({
1104
+ paths,
1105
+ routeParams,
1106
+ channelParams,
1107
+ getResource
1108
+ });
1109
+ latest.current = {
1110
+ paths,
1111
+ routeParams,
1112
+ channelParams,
1113
+ getResource
1114
+ };
1115
+ const invalidate = () => {
1116
+ const { paths, routeParams, channelParams, getResource } = latest.current;
1117
+ for (const entry of paths) {
1118
+ const target = typeof entry === "string" ? { path: entry } : entry;
1119
+ const resource = getResource(applyParams$1(target.path, {
1120
+ ...routeParams,
1121
+ ...channelParams,
1122
+ ...target.params
1123
+ }));
1124
+ const keys = target.search ? [toVariantKey(target.search)] : resource.variantKeys();
1125
+ for (const key of keys) resource.invalidate(key);
1591
1126
  }
1592
- } finally {
1593
- await reader.cancel().catch(() => {});
1594
- }
1127
+ };
1128
+ return useChannelSubscription(pattern, channelParams, {
1129
+ onEvent: useCoalesced(invalidate),
1130
+ onResync: invalidate
1131
+ }, config.enabled !== false);
1595
1132
  }
1596
1133
  //#endregion
1597
1134
  //#region client/Mutation.tsx