@fragno-dev/core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/.turbo/turbo-build.log +61 -0
  2. package/.turbo/turbo-types$colon$check.log +2 -0
  3. package/dist/api/api.d.ts +2 -0
  4. package/dist/api/api.js +3 -0
  5. package/dist/api-CBDGZiLC.d.ts +278 -0
  6. package/dist/api-CBDGZiLC.d.ts.map +1 -0
  7. package/dist/api-DgHfYjq2.js +54 -0
  8. package/dist/api-DgHfYjq2.js.map +1 -0
  9. package/dist/client/client.d.ts +3 -0
  10. package/dist/client/client.js +6 -0
  11. package/dist/client/client.svelte.d.ts +33 -0
  12. package/dist/client/client.svelte.d.ts.map +1 -0
  13. package/dist/client/client.svelte.js +123 -0
  14. package/dist/client/client.svelte.js.map +1 -0
  15. package/dist/client/react.d.ts +58 -0
  16. package/dist/client/react.d.ts.map +1 -0
  17. package/dist/client/react.js +80 -0
  18. package/dist/client/react.js.map +1 -0
  19. package/dist/client/vanilla.d.ts +61 -0
  20. package/dist/client/vanilla.d.ts.map +1 -0
  21. package/dist/client/vanilla.js +136 -0
  22. package/dist/client/vanilla.js.map +1 -0
  23. package/dist/client/vue.d.ts +39 -0
  24. package/dist/client/vue.d.ts.map +1 -0
  25. package/dist/client/vue.js +108 -0
  26. package/dist/client/vue.js.map +1 -0
  27. package/dist/client-DWjxKDnE.js +703 -0
  28. package/dist/client-DWjxKDnE.js.map +1 -0
  29. package/dist/client-XFdAy-IQ.d.ts +287 -0
  30. package/dist/client-XFdAy-IQ.d.ts.map +1 -0
  31. package/dist/integrations/astro.d.ts +18 -0
  32. package/dist/integrations/astro.d.ts.map +1 -0
  33. package/dist/integrations/astro.js +16 -0
  34. package/dist/integrations/astro.js.map +1 -0
  35. package/dist/integrations/next-js.d.ts +15 -0
  36. package/dist/integrations/next-js.d.ts.map +1 -0
  37. package/dist/integrations/next-js.js +17 -0
  38. package/dist/integrations/next-js.js.map +1 -0
  39. package/dist/integrations/react-ssr.d.ts +19 -0
  40. package/dist/integrations/react-ssr.d.ts.map +1 -0
  41. package/dist/integrations/react-ssr.js +38 -0
  42. package/dist/integrations/react-ssr.js.map +1 -0
  43. package/dist/integrations/svelte-kit.d.ts +21 -0
  44. package/dist/integrations/svelte-kit.d.ts.map +1 -0
  45. package/dist/integrations/svelte-kit.js +18 -0
  46. package/dist/integrations/svelte-kit.js.map +1 -0
  47. package/dist/mod.d.ts +3 -0
  48. package/dist/mod.js +177 -0
  49. package/dist/mod.js.map +1 -0
  50. package/dist/route-Bp6eByhz.js +331 -0
  51. package/dist/route-Bp6eByhz.js.map +1 -0
  52. package/dist/ssr-tJHqcNSw.js +48 -0
  53. package/dist/ssr-tJHqcNSw.js.map +1 -0
  54. package/package.json +127 -0
  55. package/src/api/api.test.ts +140 -0
  56. package/src/api/api.ts +106 -0
  57. package/src/api/error.ts +47 -0
  58. package/src/api/fragment.test.ts +509 -0
  59. package/src/api/fragment.ts +277 -0
  60. package/src/api/internal/path-runtime.test.ts +121 -0
  61. package/src/api/internal/path-type.test.ts +602 -0
  62. package/src/api/internal/path.ts +322 -0
  63. package/src/api/internal/response-stream.ts +118 -0
  64. package/src/api/internal/route.test.ts +56 -0
  65. package/src/api/internal/route.ts +9 -0
  66. package/src/api/request-input-context.test.ts +437 -0
  67. package/src/api/request-input-context.ts +201 -0
  68. package/src/api/request-middleware.test.ts +544 -0
  69. package/src/api/request-middleware.ts +126 -0
  70. package/src/api/request-output-context.test.ts +626 -0
  71. package/src/api/request-output-context.ts +175 -0
  72. package/src/api/route.test.ts +176 -0
  73. package/src/api/route.ts +152 -0
  74. package/src/client/client-builder.test.ts +264 -0
  75. package/src/client/client-error.test.ts +15 -0
  76. package/src/client/client-error.ts +141 -0
  77. package/src/client/client-types.test.ts +493 -0
  78. package/src/client/client.ssr.test.ts +173 -0
  79. package/src/client/client.svelte.test.ts +837 -0
  80. package/src/client/client.svelte.ts +278 -0
  81. package/src/client/client.test.ts +1690 -0
  82. package/src/client/client.ts +1035 -0
  83. package/src/client/component.test.svelte +21 -0
  84. package/src/client/internal/ndjson-streaming.test.ts +457 -0
  85. package/src/client/internal/ndjson-streaming.ts +248 -0
  86. package/src/client/react.test.ts +947 -0
  87. package/src/client/react.ts +241 -0
  88. package/src/client/vanilla.test.ts +867 -0
  89. package/src/client/vanilla.ts +265 -0
  90. package/src/client/vue.test.ts +754 -0
  91. package/src/client/vue.ts +242 -0
  92. package/src/http/http-status.ts +60 -0
  93. package/src/integrations/astro.ts +17 -0
  94. package/src/integrations/next-js.ts +31 -0
  95. package/src/integrations/react-ssr.ts +40 -0
  96. package/src/integrations/svelte-kit.ts +41 -0
  97. package/src/mod.ts +20 -0
  98. package/src/util/async.test.ts +85 -0
  99. package/src/util/async.ts +96 -0
  100. package/src/util/content-type.test.ts +136 -0
  101. package/src/util/content-type.ts +84 -0
  102. package/src/util/nanostores.test.ts +28 -0
  103. package/src/util/nanostores.ts +65 -0
  104. package/src/util/ssr.ts +75 -0
  105. package/src/util/types-util.ts +16 -0
  106. package/tsconfig.json +10 -0
  107. package/tsdown.config.ts +21 -0
  108. package/vitest.config.ts +10 -0
@@ -0,0 +1,242 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import { atom, type ReadableAtom, type Store, type StoreValue } from "nanostores";
3
+ import type { DeepReadonly, Ref, ShallowRef, UnwrapNestedRefs } from "vue";
4
+ import { computed, getCurrentScope, isRef, onScopeDispose, ref, shallowRef, watch } from "vue";
5
+ import type { NonGetHTTPMethod } from "../api/api";
6
+ import {
7
+ isGetHook,
8
+ isMutatorHook,
9
+ type FragnoClientMutatorData,
10
+ type FragnoClientHookData,
11
+ } from "./client";
12
+ import type { FragnoClientError } from "./client-error";
13
+ import type { MaybeExtractPathParamsOrWiden, QueryParamsHint } from "../api/internal/path";
14
+ import type { InferOr } from "../util/types-util";
15
+
16
+ export type FragnoVueHook<
17
+ _TMethod extends "GET",
18
+ TPath extends string,
19
+ TOutputSchema extends StandardSchemaV1,
20
+ TErrorCode extends string,
21
+ TQueryParameters extends string,
22
+ > = (args?: {
23
+ path?: MaybeExtractPathParamsOrWiden<TPath, string | Ref<string> | ReadableAtom<string>>;
24
+ query?: QueryParamsHint<TQueryParameters, string | Ref<string> | ReadableAtom<string>>;
25
+ }) => {
26
+ data: Ref<InferOr<TOutputSchema, undefined>>;
27
+ loading: Ref<boolean>;
28
+ error: Ref<FragnoClientError<TErrorCode[number]> | undefined>;
29
+ };
30
+
31
+ export type FragnoVueMutator<
32
+ _TMethod extends NonGetHTTPMethod,
33
+ TPath extends string,
34
+ TInputSchema extends StandardSchemaV1 | undefined,
35
+ TOutputSchema extends StandardSchemaV1 | undefined,
36
+ TErrorCode extends string,
37
+ TQueryParameters extends string,
38
+ > = () => {
39
+ mutate: (args: {
40
+ body?: InferOr<TInputSchema, undefined>;
41
+ path?: MaybeExtractPathParamsOrWiden<TPath, string | Ref<string> | ReadableAtom<string>>;
42
+ query?: QueryParamsHint<TQueryParameters, string | Ref<string> | ReadableAtom<string>>;
43
+ }) => Promise<InferOr<TOutputSchema, undefined>>;
44
+ loading: Ref<boolean | undefined>;
45
+ error: Ref<FragnoClientError<TErrorCode[number]> | undefined>;
46
+ data: Ref<InferOr<TOutputSchema, undefined>>;
47
+ };
48
+
49
+ /**
50
+ * Converts a Vue Ref to a NanoStore Atom.
51
+ *
52
+ * This is used to convert Vue refs to atoms, so that we can use them in the store.
53
+ *
54
+ * @private
55
+ */
56
+ export function refToAtom<T>(ref: Ref<T>): ReadableAtom<T> {
57
+ const a = atom(ref.value);
58
+
59
+ watch(ref, (newVal) => {
60
+ a.set(newVal);
61
+ });
62
+
63
+ // TODO: Do we need to unsubscribe, or is this handled by `onScopeDispose` below?
64
+
65
+ return a;
66
+ }
67
+
68
+ // Helper function to create a Vue composable from a GET hook
69
+ // We want 1 store per hook, so on updates to params, we need to update the store instead of creating a new one.
70
+ // Nanostores only works with atoms (or strings), so we need to convert vue refs to atoms.
71
+ function createVueHook<
72
+ TMethod extends "GET",
73
+ TPath extends string,
74
+ TOutputSchema extends StandardSchemaV1,
75
+ TErrorCode extends string,
76
+ TQueryParameters extends string,
77
+ >(
78
+ hook: FragnoClientHookData<TMethod, TPath, TOutputSchema, TErrorCode, TQueryParameters>,
79
+ ): FragnoVueHook<TMethod, TPath, TOutputSchema, TErrorCode, TQueryParameters> {
80
+ return ({ path, query } = {}) => {
81
+ const pathParams: Record<string, string | ReadableAtom<string>> = {};
82
+ const queryParams: Record<string, string | ReadableAtom<string>> = {};
83
+
84
+ for (const [key, value] of Object.entries(path ?? {})) {
85
+ const v = value as string | Ref<string> | ReadableAtom<string>;
86
+ pathParams[key] = isRef(v) ? refToAtom(v) : v;
87
+ }
88
+
89
+ for (const [key, value] of Object.entries(query ?? {})) {
90
+ // Dunno why the cast is necessary
91
+ const v = value as string | Ref<string> | ReadableAtom<string>;
92
+ queryParams[key] = isRef(v) ? (refToAtom(v) as ReadableAtom<string>) : v;
93
+ }
94
+
95
+ const store = hook.store({
96
+ path: pathParams as MaybeExtractPathParamsOrWiden<TPath, string | ReadableAtom<string>>,
97
+ query: queryParams,
98
+ });
99
+
100
+ const data = ref();
101
+ const loading = ref();
102
+ const error = ref();
103
+
104
+ const unsubscribe = store.subscribe((updatedStoreValue) => {
105
+ data.value = updatedStoreValue.data;
106
+ loading.value = updatedStoreValue.loading;
107
+ error.value = updatedStoreValue.error;
108
+ });
109
+
110
+ if (getCurrentScope()) {
111
+ onScopeDispose(() => {
112
+ unsubscribe();
113
+ });
114
+ }
115
+
116
+ return {
117
+ data,
118
+ loading,
119
+ error,
120
+ };
121
+ };
122
+ }
123
+
124
+ // Helper function to create a Vue mutator from a mutator hook
125
+ function createVueMutator<
126
+ TMethod extends NonGetHTTPMethod,
127
+ TPath extends string,
128
+ TInputSchema extends StandardSchemaV1 | undefined,
129
+ TOutputSchema extends StandardSchemaV1 | undefined,
130
+ TErrorCode extends string,
131
+ TQueryParameters extends string,
132
+ >(
133
+ hook: FragnoClientMutatorData<
134
+ TMethod,
135
+ TPath,
136
+ TInputSchema,
137
+ TOutputSchema,
138
+ TErrorCode,
139
+ TQueryParameters
140
+ >,
141
+ ): FragnoVueMutator<TMethod, TPath, TInputSchema, TOutputSchema, TErrorCode, TQueryParameters> {
142
+ return () => {
143
+ const store = useStore(hook.mutatorStore);
144
+
145
+ // Create a wrapped mutate function that handles Vue refs
146
+ const mutate = async (args: {
147
+ body?: InferOr<TInputSchema, undefined>;
148
+ path?: MaybeExtractPathParamsOrWiden<TPath, string | Ref<string> | ReadableAtom<string>>;
149
+ query?: QueryParamsHint<TQueryParameters, string | Ref<string> | ReadableAtom<string>>;
150
+ }) => {
151
+ const { body, path, query } = args;
152
+
153
+ const pathParams: Record<string, string | ReadableAtom<string>> = {};
154
+ const queryParams: Record<string, string | ReadableAtom<string>> = {};
155
+
156
+ for (const [key, value] of Object.entries(path ?? {})) {
157
+ const v = value as string | Ref<string> | ReadableAtom<string>;
158
+ pathParams[key] = isRef(v) ? v.value : v;
159
+ }
160
+
161
+ for (const [key, value] of Object.entries(query ?? {})) {
162
+ const v = value as string | Ref<string> | ReadableAtom<string>;
163
+ queryParams[key] = isRef(v) ? v.value : v;
164
+ }
165
+
166
+ // Call the store's mutate function with normalized params
167
+ return store.value.mutate({
168
+ body,
169
+ path: pathParams as MaybeExtractPathParamsOrWiden<TPath, string | ReadableAtom<string>>,
170
+ query: queryParams,
171
+ });
172
+ };
173
+
174
+ // Return the store-like object with Vue reactive refs
175
+ return {
176
+ mutate,
177
+ loading: computed(() => store.value.loading),
178
+ error: computed(() => store.value.error),
179
+ data: computed(() => store.value.data) as Ref<InferOr<TOutputSchema, undefined>>,
180
+ };
181
+ };
182
+ }
183
+
184
+ export function useFragno<T extends Record<string, unknown>>(
185
+ clientObj: T,
186
+ ): {
187
+ [K in keyof T]: T[K] extends FragnoClientHookData<
188
+ "GET",
189
+ infer TPath,
190
+ infer TOutputSchema,
191
+ infer TErrorCode,
192
+ infer TQueryParameters
193
+ >
194
+ ? FragnoVueHook<"GET", TPath, TOutputSchema, TErrorCode, TQueryParameters>
195
+ : T[K] extends FragnoClientMutatorData<
196
+ infer M,
197
+ infer TPath,
198
+ infer TInputSchema,
199
+ infer TOutputSchema,
200
+ infer TErrorCode,
201
+ infer TQueryParameters
202
+ >
203
+ ? FragnoVueMutator<M, TPath, TInputSchema, TOutputSchema, TErrorCode, TQueryParameters>
204
+ : T[K];
205
+ } {
206
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
207
+ const result = {} as any;
208
+
209
+ for (const key in clientObj) {
210
+ if (!Object.prototype.hasOwnProperty.call(clientObj, key)) {
211
+ continue;
212
+ }
213
+
214
+ const hook = clientObj[key];
215
+ if (isGetHook(hook)) {
216
+ result[key] = createVueHook(hook);
217
+ } else if (isMutatorHook(hook)) {
218
+ result[key] = createVueMutator(hook);
219
+ } else {
220
+ // Pass through non-hook values unchanged
221
+ result[key] = hook;
222
+ }
223
+ }
224
+
225
+ return result;
226
+ }
227
+
228
+ export function useStore<SomeStore extends Store, Value extends StoreValue<SomeStore>>(
229
+ store: SomeStore,
230
+ ): DeepReadonly<UnwrapNestedRefs<ShallowRef<Value>>> {
231
+ const state = shallowRef();
232
+
233
+ const unsubscribe = store.subscribe((value) => {
234
+ state.value = value;
235
+ });
236
+
237
+ if (getCurrentScope()) {
238
+ onScopeDispose(unsubscribe);
239
+ }
240
+
241
+ return state;
242
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @module
3
+ * HTTP Status utility.
4
+ *
5
+ * Modified from honojs/hono
6
+ * Original source: https://github.com/honojs/hono/blob/0e3db674ad3f40be215a55a18062dd8e387ce525/src/utils/http-status.ts
7
+ * License: MIT
8
+ * Date obtained: August 28 2025
9
+ * Copyright (c) 2021-present Yusuke Wada and Hono contributors
10
+ *
11
+ */
12
+
13
+ export type InfoStatusCode = 100 | 101 | 102 | 103;
14
+ export type SuccessStatusCode = 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 226;
15
+ export type DeprecatedStatusCode = 305 | 306;
16
+ export type RedirectStatusCode = 300 | 301 | 302 | 303 | 304 | DeprecatedStatusCode | 307 | 308;
17
+ export type ClientErrorStatusCode =
18
+ | 400
19
+ | 401
20
+ | 402
21
+ | 403
22
+ | 404
23
+ | 405
24
+ | 406
25
+ | 407
26
+ | 408
27
+ | 409
28
+ | 410
29
+ | 411
30
+ | 412
31
+ | 413
32
+ | 414
33
+ | 415
34
+ | 416
35
+ | 417
36
+ | 418
37
+ | 421
38
+ | 422
39
+ | 423
40
+ | 424
41
+ | 425
42
+ | 426
43
+ | 428
44
+ | 429
45
+ | 431
46
+ | 451;
47
+ export type ServerErrorStatusCode = 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 510 | 511;
48
+
49
+ /**
50
+ * If you want to use an unofficial status, use `UnofficialStatusCode`.
51
+ */
52
+ export type StatusCode =
53
+ | InfoStatusCode
54
+ | SuccessStatusCode
55
+ | RedirectStatusCode
56
+ | ClientErrorStatusCode
57
+ | ServerErrorStatusCode;
58
+
59
+ export type ContentlessStatusCode = 101 | 204 | 205 | 304;
60
+ export type ContentfulStatusCode = Exclude<StatusCode, ContentlessStatusCode>;
@@ -0,0 +1,17 @@
1
+ export interface AstroHandlers {
2
+ ALL: ({ request }: { request: Request }) => Promise<Response>;
3
+ }
4
+
5
+ /**
6
+ * Converts a Fragno fragment handler to an Astro API route handler
7
+ *
8
+ * @param fragment - The Fragno fragment instance
9
+ * @returns An Astro API route handler function
10
+ */
11
+ export function toAstroHandler(handler: (req: Request) => Promise<Response>): AstroHandlers {
12
+ return {
13
+ ALL: async ({ request }) => {
14
+ return handler(request);
15
+ },
16
+ };
17
+ }
@@ -0,0 +1,31 @@
1
+ export interface NextJsHandlers {
2
+ GET: (request: Request) => Promise<Response>;
3
+ POST: (request: Request) => Promise<Response>;
4
+ PUT: (request: Request) => Promise<Response>;
5
+ PATCH: (request: Request) => Promise<Response>;
6
+ DELETE: (request: Request) => Promise<Response>;
7
+ }
8
+
9
+ export function toNextJsHandler<T extends { handler: (req: Request) => Promise<Response> }>(
10
+ fragment: T,
11
+ ): NextJsHandlers;
12
+ export function toNextJsHandler(handler: (req: Request) => Promise<Response>): NextJsHandlers;
13
+ export function toNextJsHandler(
14
+ fragmentOrHandler:
15
+ | { handler: (req: Request) => Promise<Response> }
16
+ | ((req: Request) => Promise<Response>),
17
+ ): NextJsHandlers {
18
+ const handler = async (request: Request) => {
19
+ return "handler" in fragmentOrHandler
20
+ ? fragmentOrHandler.handler(request)
21
+ : fragmentOrHandler(request);
22
+ };
23
+
24
+ return {
25
+ GET: handler,
26
+ POST: handler,
27
+ PUT: handler,
28
+ PATCH: handler,
29
+ DELETE: handler,
30
+ };
31
+ }
@@ -0,0 +1,40 @@
1
+ import { cleanStores, getFinalStoreValues } from "../util/ssr";
2
+
3
+ /**
4
+ * Advice from https://pragmaticwebsecurity.com/articles/spasecurity/json-stringify-xss.html
5
+ * @param obj
6
+ * @returns
7
+ */
8
+ function javascriptEscaped(obj: unknown) {
9
+ return JSON.stringify(obj).replace(/</g, "\\u003c");
10
+ }
11
+
12
+ /**
13
+ * This method should be called after a first render pass is finished.
14
+ * It gets all values from the stores and embeds them in a script tag in the HTML body.
15
+ *
16
+ * On the client side, this script tag is used to hydrate the stores.
17
+ * Be sure to also call finishServerLoad when the page is rendered, to reset the stores for the next request.
18
+ *
19
+ * @returns A string to be embedded in a script tag in the HTML body
20
+ */
21
+ export async function startServerLoad(): Promise<string> {
22
+ const initialStoreValues = await getFinalStoreValues();
23
+
24
+ console.log("initialStoreValues", initialStoreValues);
25
+
26
+ return `window.__FRAGNO_INITIAL_DATA__ = ${javascriptEscaped(
27
+ Array.from(initialStoreValues.entries()),
28
+ )}`;
29
+ }
30
+
31
+ export async function initServerLoad() {
32
+ cleanStores();
33
+ }
34
+
35
+ /**
36
+ * Reset the stores for the next request.
37
+ */
38
+ export async function finishServerLoad() {
39
+ cleanStores();
40
+ }
@@ -0,0 +1,41 @@
1
+ type MaybePromise<T> = T | Promise<T>;
2
+
3
+ type SvelteKitRequestEvent = {
4
+ request: Request;
5
+ };
6
+
7
+ export type SvelteKitRequestHandler = (event: SvelteKitRequestEvent) => MaybePromise<Response>;
8
+
9
+ export interface SvelteKitHandlers {
10
+ GET: SvelteKitRequestHandler;
11
+ POST: SvelteKitRequestHandler;
12
+ PUT: SvelteKitRequestHandler;
13
+ PATCH: SvelteKitRequestHandler;
14
+ DELETE: SvelteKitRequestHandler;
15
+ OPTIONS: SvelteKitRequestHandler;
16
+ }
17
+
18
+ export function toSvelteHandler<T extends { handler: (req: Request) => Promise<Response> }>(
19
+ fragment: T,
20
+ ): SvelteKitHandlers;
21
+ export function toSvelteHandler(handler: (req: Request) => Promise<Response>): SvelteKitHandlers;
22
+ export function toSvelteHandler(
23
+ fragmentOrHandler:
24
+ | { handler: (req: Request) => Promise<Response> }
25
+ | ((req: Request) => Promise<Response>),
26
+ ): SvelteKitHandlers {
27
+ const requestHandler: SvelteKitRequestHandler = async ({ request }) => {
28
+ return "handler" in fragmentOrHandler
29
+ ? fragmentOrHandler.handler(request)
30
+ : fragmentOrHandler(request);
31
+ };
32
+
33
+ return {
34
+ GET: requestHandler,
35
+ POST: requestHandler,
36
+ PUT: requestHandler,
37
+ PATCH: requestHandler,
38
+ DELETE: requestHandler,
39
+ OPTIONS: requestHandler,
40
+ };
41
+ }
package/src/mod.ts ADDED
@@ -0,0 +1,20 @@
1
+ export {
2
+ defineFragment,
3
+ createFragment,
4
+ FragmentBuilder,
5
+ type FragnoFragmentSharedConfig,
6
+ type FragnoPublicConfig,
7
+ type FragnoPublicClientConfig,
8
+ type FragnoInstantiatedFragment,
9
+ } from "./api/fragment";
10
+
11
+ export { type FragnoRouteConfig } from "./api/api";
12
+
13
+ export {
14
+ defineRoute,
15
+ defineRoutes,
16
+ type RouteFactory,
17
+ type RouteFactoryContext,
18
+ type AnyRouteOrFactory,
19
+ type FlattenRouteFactories,
20
+ } from "./api/route";
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { createAsyncIteratorFromCallback, waitForAsyncIterator } from "./async";
3
+
4
+ describe("createAsyncIteratorFromCallback", () => {
5
+ test("yields values from callback", async () => {
6
+ let callback: ((value: string) => void) | null = null;
7
+ let unsubscribeCalled = false;
8
+
9
+ const subscribe = (cb: (value: string) => void) => {
10
+ callback = cb;
11
+ return () => {
12
+ unsubscribeCalled = true;
13
+ };
14
+ };
15
+
16
+ const iterator = createAsyncIteratorFromCallback(subscribe);
17
+
18
+ // Start consuming the iterator
19
+ const consumePromise = (async () => {
20
+ const values: string[] = [];
21
+ for await (const value of iterator) {
22
+ values.push(value);
23
+ if (values.length === 3) break;
24
+ }
25
+ return values;
26
+ })();
27
+
28
+ // Emit values
29
+ callback!("first");
30
+ callback!("second");
31
+ callback!("third");
32
+
33
+ const result = await consumePromise;
34
+ expect(result).toEqual(["first", "second", "third"]);
35
+ expect(unsubscribeCalled).toBe(true);
36
+ });
37
+
38
+ test("calls unsubscribe when iterator is terminated early", async () => {
39
+ let callback: ((value: string) => void) | null = null;
40
+ let unsubscribeCalled = false;
41
+
42
+ const subscribe = (cb: (value: string) => void) => {
43
+ callback = cb;
44
+ return () => {
45
+ unsubscribeCalled = true;
46
+ };
47
+ };
48
+
49
+ const iterator = createAsyncIteratorFromCallback(subscribe);
50
+
51
+ // Start consuming but break early
52
+ const consumePromise = (async () => {
53
+ const values: string[] = [];
54
+ for await (const value of iterator) {
55
+ values.push(value);
56
+ if (values.length === 2) break; // Break after 2 values
57
+ }
58
+ return values;
59
+ })();
60
+
61
+ // Emit 3 values
62
+ callback!("first");
63
+ callback!("second");
64
+ callback!("third");
65
+
66
+ const result = await consumePromise;
67
+ expect(result).toEqual(["first", "second"]);
68
+ expect(unsubscribeCalled).toBe(true);
69
+ });
70
+ });
71
+
72
+ describe("waitForAsyncIterator", () => {
73
+ test("actually times out", async () => {
74
+ const iterable = {
75
+ [Symbol.asyncIterator]: async function* () {
76
+ await new Promise((resolve) => setTimeout(resolve, 5000));
77
+ yield "test";
78
+ },
79
+ };
80
+
81
+ await expect(waitForAsyncIterator(iterable, () => false, { timeout: 1 })).rejects.toThrow(
82
+ "waitForAsyncIterator: Timeout after 1ms",
83
+ );
84
+ });
85
+ });
@@ -0,0 +1,96 @@
1
+ type SubscribeFn<T> = (callback: (value: T) => void) => UnsubscribeFn | void;
2
+ type UnsubscribeFn = () => void;
3
+
4
+ /**
5
+ * Creates an async iterator from a subscribe function that follows the observable pattern.
6
+ *
7
+ * @template T The type of values produced by the store.
8
+ * @param subscribe A function that subscribes to store updates. It receives a callback to be
9
+ * called on each update, and returns an unsubscribe function.
10
+ * @returns An async generator that yields store values as they are produced.
11
+ */
12
+ export function createAsyncIteratorFromCallback<T>(
13
+ subscribe: SubscribeFn<T>,
14
+ ): AsyncGenerator<T, void, unknown> {
15
+ const queue: T[] = [];
16
+ let unsubscribe: UnsubscribeFn | null = null;
17
+ let resolveNext: ((value: IteratorResult<T>) => void) | null = null;
18
+
19
+ const unsubscribeFunc = subscribe((value) => {
20
+ if (resolveNext) {
21
+ // If there's a pending promise, resolve it immediately
22
+ resolveNext({ value, done: false });
23
+ resolveNext = null;
24
+ } else {
25
+ // Otherwise, queue the value
26
+ queue.push(value);
27
+ }
28
+ });
29
+
30
+ // Store unsubscribe function if one was returned
31
+ if (typeof unsubscribeFunc === "function") {
32
+ unsubscribe = unsubscribeFunc;
33
+ }
34
+
35
+ return (async function* () {
36
+ try {
37
+ while (true) {
38
+ if (queue.length > 0) {
39
+ // Yield queued values
40
+ yield queue.shift()!;
41
+ } else {
42
+ // Wait for the next value
43
+ yield await new Promise<T>((resolve) => {
44
+ resolveNext = (result) => {
45
+ if (!result.done) {
46
+ resolve(result.value);
47
+ }
48
+ };
49
+ });
50
+ }
51
+ }
52
+ } finally {
53
+ // Clean up subscription on iterator termination
54
+ if (unsubscribe) {
55
+ unsubscribe();
56
+ }
57
+ }
58
+ })();
59
+ }
60
+
61
+ /**
62
+ * Waits for an async iterator to yield a value that meets a condition.
63
+ *
64
+ * @template T The type of values produced by the iterator.
65
+ * @param iterable The async iterable to wait for.
66
+ * @param condition A function that checks if a value meets the condition.
67
+ * @param options Optional configuration options.
68
+ * @returns A promise that resolves to the first value that meets the condition.
69
+ */
70
+ export async function waitForAsyncIterator<T>(
71
+ iterable: AsyncIterable<T>,
72
+ condition: (value: T) => boolean,
73
+ options: { timeout?: number } = {},
74
+ ): Promise<T> {
75
+ const { timeout = 1000 } = options;
76
+
77
+ // Create a timeout promise that rejects after the specified time
78
+ const timeoutPromise = new Promise<never>((_, reject) => {
79
+ setTimeout(() => {
80
+ reject(new Error(`waitForAsyncIterator: Timeout after ${timeout}ms`));
81
+ }, timeout);
82
+ });
83
+
84
+ // Create a promise that resolves when the condition is met
85
+ const iteratorPromise = (async () => {
86
+ for await (const value of iterable) {
87
+ if (condition(value)) {
88
+ return value;
89
+ }
90
+ }
91
+ throw new Error("waitForAsyncIterator: Iterator completed without meeting condition");
92
+ })();
93
+
94
+ // Race between the timeout and the iterator
95
+ return Promise.race([iteratorPromise, timeoutPromise]);
96
+ }