@procedures/splay 0.0.0-stage → 0.2.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.
- package/CHANGELOG.md +27 -0
- package/LICENSE +21 -0
- package/README.md +408 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/components/define.d.ts +134 -0
- package/dist/components/define.d.ts.map +1 -0
- package/dist/components/define.js +486 -0
- package/dist/components/define.js.map +1 -0
- package/dist/components/index.d.ts +39 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +38 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/types.d.ts +264 -0
- package/dist/components/types.d.ts.map +1 -0
- package/dist/components/types.js +46 -0
- package/dist/components/types.js.map +1 -0
- package/dist/hydrate.d.ts +126 -0
- package/dist/hydrate.d.ts.map +1 -0
- package/dist/hydrate.js +226 -0
- package/dist/hydrate.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +53 -0
- package/dist/index.js.map +1 -0
- package/dist/module.d.ts +24 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +68 -0
- package/dist/module.js.map +1 -0
- package/dist/register.d.ts +7 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +9 -0
- package/dist/register.js.map +1 -0
- package/dist/registry.d.ts +60 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +191 -0
- package/dist/registry.js.map +1 -0
- package/dist/streaming.d.ts +94 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +422 -0
- package/dist/streaming.js.map +1 -0
- package/dist/types.d.ts +85 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/package.json +76 -3
- package/src/components/define.ts +600 -0
- package/src/components/index.ts +78 -0
- package/src/components/types.ts +357 -0
- package/src/hydrate.ts +355 -0
- package/src/index.ts +110 -0
- package/src/module.ts +93 -0
- package/src/register.ts +11 -0
- package/src/registry.ts +257 -0
- package/src/streaming.ts +554 -0
- package/src/types.ts +114 -0
package/src/streaming.ts
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming Registry
|
|
3
|
+
*
|
|
4
|
+
* Creates a splay-compatible StreamingRegistry for components that
|
|
5
|
+
* yield multiple outputs over time (live updates, progressive rendering).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ComponentOutput } from "./components/index.js";
|
|
9
|
+
import type {
|
|
10
|
+
StreamingRegistry,
|
|
11
|
+
StreamingComponentRenderer,
|
|
12
|
+
RenderContext,
|
|
13
|
+
StreamingRegistryOptions,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
// Portable timer types (cross-platform)
|
|
17
|
+
type TimerFn = (callback: () => void, ms: number) => number;
|
|
18
|
+
type ClearFn = (id: number) => void;
|
|
19
|
+
type GlobalWithTimers = { setTimeout: TimerFn; clearTimeout: ClearFn };
|
|
20
|
+
type TimerId = number;
|
|
21
|
+
const setTimer = (globalThis as unknown as GlobalWithTimers).setTimeout;
|
|
22
|
+
const clearTimer = (globalThis as unknown as GlobalWithTimers).clearTimeout;
|
|
23
|
+
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Streaming Procedure Caller Type
|
|
26
|
+
// =============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Type for calling streaming client procedures.
|
|
30
|
+
* Returns an async iterable of results.
|
|
31
|
+
*/
|
|
32
|
+
export interface StreamingProcedureCaller {
|
|
33
|
+
<TResult>(path: string[], input: unknown): AsyncIterable<TResult>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// Streaming Registry Implementation
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a splay-compatible streaming registry backed by client procedures.
|
|
42
|
+
*
|
|
43
|
+
* @param callStreaming - Client's streaming procedure call function
|
|
44
|
+
* @param options - Registry options
|
|
45
|
+
* @returns StreamingRegistry instance
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* import { stream } from "client";
|
|
50
|
+
* import { createStreamingRegistry } from "@procedures/splay";
|
|
51
|
+
*
|
|
52
|
+
* const registry = createStreamingRegistry(stream, {
|
|
53
|
+
* namespace: "live",
|
|
54
|
+
* bufferSize: 10,
|
|
55
|
+
* });
|
|
56
|
+
*
|
|
57
|
+
* // Use for live-updating components
|
|
58
|
+
* for await (const output of registry.get("ticker")!(ctx)) {
|
|
59
|
+
* render(output);
|
|
60
|
+
* }
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function createStreamingRegistry(
|
|
64
|
+
callStreaming: StreamingProcedureCaller,
|
|
65
|
+
options: StreamingRegistryOptions = {}
|
|
66
|
+
): StreamingRegistry {
|
|
67
|
+
const { namespace, bufferSize = 16 } = options;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build the procedure path for a component type.
|
|
71
|
+
*/
|
|
72
|
+
function buildPath(type: string): string[] {
|
|
73
|
+
const basePath = ["components"];
|
|
74
|
+
if (namespace) {
|
|
75
|
+
basePath.push(namespace);
|
|
76
|
+
}
|
|
77
|
+
basePath.push(type);
|
|
78
|
+
return basePath;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create a streaming renderer for a component type.
|
|
83
|
+
*/
|
|
84
|
+
function createRenderer(type: string): StreamingComponentRenderer {
|
|
85
|
+
const procedurePath = buildPath(type);
|
|
86
|
+
|
|
87
|
+
return async function* (ctx: RenderContext): AsyncIterable<ComponentOutput> {
|
|
88
|
+
// Build the input for the component procedure
|
|
89
|
+
const input = {
|
|
90
|
+
data: ctx.data,
|
|
91
|
+
size: ctx.size,
|
|
92
|
+
path: ctx.path,
|
|
93
|
+
depth: ctx.depth,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Stream from the procedure
|
|
97
|
+
const stream = callStreaming<ComponentOutput>(procedurePath, input);
|
|
98
|
+
|
|
99
|
+
// Yield each output with optional buffering
|
|
100
|
+
let buffer: ComponentOutput[] = [];
|
|
101
|
+
|
|
102
|
+
for await (const output of stream) {
|
|
103
|
+
if (bufferSize <= 1) {
|
|
104
|
+
// No buffering - yield immediately
|
|
105
|
+
yield output;
|
|
106
|
+
} else {
|
|
107
|
+
// Add to buffer
|
|
108
|
+
buffer.push(output);
|
|
109
|
+
|
|
110
|
+
// Yield when buffer is full
|
|
111
|
+
if (buffer.length >= bufferSize) {
|
|
112
|
+
// Yield the most recent output (discard older ones)
|
|
113
|
+
yield buffer[buffer.length - 1]!;
|
|
114
|
+
buffer = [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Yield any remaining buffered output
|
|
120
|
+
if (buffer.length > 0) {
|
|
121
|
+
yield buffer[buffer.length - 1]!;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Cache of created renderers
|
|
127
|
+
const rendererCache = new Map<string, StreamingComponentRenderer>();
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
/**
|
|
131
|
+
* Get a streaming renderer for a component type.
|
|
132
|
+
*/
|
|
133
|
+
get(type: string): StreamingComponentRenderer | undefined {
|
|
134
|
+
let renderer = rendererCache.get(type);
|
|
135
|
+
if (renderer) {
|
|
136
|
+
return renderer;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
renderer = createRenderer(type);
|
|
140
|
+
rendererCache.set(type, renderer);
|
|
141
|
+
return renderer;
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check if a streaming renderer exists.
|
|
146
|
+
*/
|
|
147
|
+
has(_type: string): boolean {
|
|
148
|
+
return true; // Optimistic - actual check at call time
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// =============================================================================
|
|
154
|
+
// Dual Registry (Sync + Streaming)
|
|
155
|
+
// =============================================================================
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Combined registry supporting both sync and streaming renderers.
|
|
159
|
+
*/
|
|
160
|
+
export interface DualRegistry {
|
|
161
|
+
/** Sync registry for single-output components */
|
|
162
|
+
sync: {
|
|
163
|
+
get(type: string): ((ctx: RenderContext) => Promise<ComponentOutput>) | undefined;
|
|
164
|
+
has(type: string): boolean;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** Streaming registry for multi-output components */
|
|
168
|
+
streaming: StreamingRegistry;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Render a component, automatically choosing sync or streaming.
|
|
172
|
+
* @param type - Component type
|
|
173
|
+
* @param ctx - Render context
|
|
174
|
+
* @param preferStreaming - Prefer streaming if available
|
|
175
|
+
*/
|
|
176
|
+
render(
|
|
177
|
+
type: string,
|
|
178
|
+
ctx: RenderContext,
|
|
179
|
+
preferStreaming?: boolean
|
|
180
|
+
): AsyncIterable<ComponentOutput>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Create a dual registry supporting both sync and streaming.
|
|
185
|
+
*
|
|
186
|
+
* @param callSync - Client's sync procedure call function
|
|
187
|
+
* @param callStreaming - Client's streaming procedure call function
|
|
188
|
+
* @param isStreaming - Function to check if a component is streaming
|
|
189
|
+
* @param options - Registry options
|
|
190
|
+
* @returns DualRegistry instance
|
|
191
|
+
*/
|
|
192
|
+
export function createDualRegistry(
|
|
193
|
+
callSync: <T>(path: string[], input: unknown) => Promise<T>,
|
|
194
|
+
callStreaming: StreamingProcedureCaller,
|
|
195
|
+
isStreaming: (type: string) => boolean,
|
|
196
|
+
options: StreamingRegistryOptions = {}
|
|
197
|
+
): DualRegistry {
|
|
198
|
+
const { namespace } = options;
|
|
199
|
+
|
|
200
|
+
function buildPath(type: string): string[] {
|
|
201
|
+
const basePath = ["components"];
|
|
202
|
+
if (namespace) {
|
|
203
|
+
basePath.push(namespace);
|
|
204
|
+
}
|
|
205
|
+
basePath.push(type);
|
|
206
|
+
return basePath;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Create sync renderer
|
|
210
|
+
function createSyncRenderer(type: string) {
|
|
211
|
+
const path = buildPath(type);
|
|
212
|
+
return async (ctx: RenderContext): Promise<ComponentOutput> => {
|
|
213
|
+
return callSync<ComponentOutput>(path, {
|
|
214
|
+
data: ctx.data,
|
|
215
|
+
size: ctx.size,
|
|
216
|
+
path: ctx.path,
|
|
217
|
+
depth: ctx.depth,
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Create streaming renderer
|
|
223
|
+
function createStreamingRenderer(type: string): StreamingComponentRenderer {
|
|
224
|
+
const path = buildPath(type);
|
|
225
|
+
return async function* (ctx: RenderContext) {
|
|
226
|
+
const input = {
|
|
227
|
+
data: ctx.data,
|
|
228
|
+
size: ctx.size,
|
|
229
|
+
path: ctx.path,
|
|
230
|
+
depth: ctx.depth,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
for await (const output of callStreaming<ComponentOutput>(path, input)) {
|
|
234
|
+
yield output;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const syncCache = new Map<string, (ctx: RenderContext) => Promise<ComponentOutput>>();
|
|
240
|
+
const streamingCache = new Map<string, StreamingComponentRenderer>();
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
sync: {
|
|
244
|
+
get(type: string) {
|
|
245
|
+
let renderer = syncCache.get(type);
|
|
246
|
+
if (!renderer) {
|
|
247
|
+
renderer = createSyncRenderer(type);
|
|
248
|
+
syncCache.set(type, renderer);
|
|
249
|
+
}
|
|
250
|
+
return renderer;
|
|
251
|
+
},
|
|
252
|
+
has(type: string) {
|
|
253
|
+
return !isStreaming(type);
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
streaming: {
|
|
258
|
+
get(type: string) {
|
|
259
|
+
let renderer = streamingCache.get(type);
|
|
260
|
+
if (!renderer) {
|
|
261
|
+
renderer = createStreamingRenderer(type);
|
|
262
|
+
streamingCache.set(type, renderer);
|
|
263
|
+
}
|
|
264
|
+
return renderer;
|
|
265
|
+
},
|
|
266
|
+
has(type: string) {
|
|
267
|
+
return isStreaming(type);
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
async *render(type: string, ctx: RenderContext, preferStreaming = false) {
|
|
272
|
+
const shouldStream = preferStreaming || isStreaming(type);
|
|
273
|
+
|
|
274
|
+
if (shouldStream) {
|
|
275
|
+
const renderer = this.streaming.get(type);
|
|
276
|
+
if (renderer) {
|
|
277
|
+
yield* renderer(ctx);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Fall back to sync
|
|
283
|
+
const syncRenderer = this.sync.get(type);
|
|
284
|
+
if (syncRenderer) {
|
|
285
|
+
yield await syncRenderer(ctx);
|
|
286
|
+
} else {
|
|
287
|
+
throw new Error(`No renderer found for component type: ${type}`);
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// =============================================================================
|
|
294
|
+
// Stream Utilities
|
|
295
|
+
// =============================================================================
|
|
296
|
+
|
|
297
|
+
/** This function ends an iterator and does not wait: a source with a pending item ends after it. */
|
|
298
|
+
function endSource(iterator: AsyncIterator<unknown>): void {
|
|
299
|
+
try {
|
|
300
|
+
void Promise.resolve(iterator.return?.()).catch(() => undefined);
|
|
301
|
+
} catch {
|
|
302
|
+
// A source whose return() throws has nothing more to end
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A queue with one writer and one reader: the reader waits on one wake-up, not on each source.
|
|
308
|
+
*/
|
|
309
|
+
class Mailbox<T> {
|
|
310
|
+
private readonly items: T[] = [];
|
|
311
|
+
private wake: (() => void) | null = null;
|
|
312
|
+
private ended = false;
|
|
313
|
+
private failure: { error: unknown } | null = null;
|
|
314
|
+
|
|
315
|
+
push(item: T): void {
|
|
316
|
+
this.items.push(item);
|
|
317
|
+
this.notify();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
end(failure?: { error: unknown }): void {
|
|
321
|
+
this.ended = true;
|
|
322
|
+
if (failure) this.failure = failure;
|
|
323
|
+
this.notify();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
get isEnded(): boolean {
|
|
327
|
+
return this.ended;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private notify(): void {
|
|
331
|
+
const wake = this.wake;
|
|
332
|
+
this.wake = null;
|
|
333
|
+
wake?.();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The items, then the failure (if any) when the writer has ended. */
|
|
337
|
+
async *drain(): AsyncGenerator<T, void, undefined> {
|
|
338
|
+
for (;;) {
|
|
339
|
+
if (this.items.length > 0) {
|
|
340
|
+
yield this.items.shift()!;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (this.ended) {
|
|
344
|
+
if (this.failure) throw this.failure.error;
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
await new Promise<void>((resolve) => {
|
|
348
|
+
this.wake = resolve;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Merge multiple component streams into one.
|
|
356
|
+
* Yields outputs from all streams as they arrive.
|
|
357
|
+
*
|
|
358
|
+
* Each source has at most one pending `next()`, and the reader waits on one shared wake-up.
|
|
359
|
+
* (Before, each round raced the pending promises of all sources: an idle source collected one
|
|
360
|
+
* reaction per item of the other sources, 81 MiB at 200k items. Deep dive DATA-12.) When the
|
|
361
|
+
* reader stops, or a source fails, the function ends the other sources.
|
|
362
|
+
*/
|
|
363
|
+
export async function* mergeStreams(
|
|
364
|
+
...streams: AsyncIterable<ComponentOutput>[]
|
|
365
|
+
): AsyncIterable<ComponentOutput> {
|
|
366
|
+
type Settled =
|
|
367
|
+
| { index: number; result: IteratorResult<ComponentOutput> }
|
|
368
|
+
| { index: number; error: unknown };
|
|
369
|
+
|
|
370
|
+
const iterators = streams.map((stream) => stream[Symbol.asyncIterator]());
|
|
371
|
+
const active = new Set<number>();
|
|
372
|
+
const settled = new Mailbox<Settled>();
|
|
373
|
+
|
|
374
|
+
const pull = (index: number): void => {
|
|
375
|
+
iterators[index]!.next().then(
|
|
376
|
+
(result) => settled.push({ index, result }),
|
|
377
|
+
(error: unknown) => settled.push({ index, error }),
|
|
378
|
+
);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
iterators.forEach((_, index) => {
|
|
382
|
+
active.add(index);
|
|
383
|
+
pull(index);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
if (active.size === 0) return;
|
|
388
|
+
for await (const item of settled.drain()) {
|
|
389
|
+
if ("error" in item) {
|
|
390
|
+
active.delete(item.index);
|
|
391
|
+
throw item.error;
|
|
392
|
+
}
|
|
393
|
+
if (item.result.done) {
|
|
394
|
+
active.delete(item.index);
|
|
395
|
+
if (active.size === 0) return;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
yield item.result.value;
|
|
399
|
+
// The next item of this source, after the reader took this one
|
|
400
|
+
pull(item.index);
|
|
401
|
+
}
|
|
402
|
+
} finally {
|
|
403
|
+
for (const index of active) endSource(iterators[index]!);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The state of a timed stream: the source, the outputs, and the stop flag of the reader. */
|
|
408
|
+
interface TimedStream {
|
|
409
|
+
iterator: AsyncIterator<ComponentOutput>;
|
|
410
|
+
outputs: Mailbox<ComponentOutput>;
|
|
411
|
+
stopped: boolean;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Read a source in the background and give each item to `onItem`. `onEnd` runs when the source
|
|
416
|
+
* ends or fails, before the outputs end. When the reader stops, the source is not read further.
|
|
417
|
+
*/
|
|
418
|
+
async function* timedStream(
|
|
419
|
+
stream: AsyncIterable<ComponentOutput>,
|
|
420
|
+
onItem: (output: ComponentOutput, state: TimedStream) => void,
|
|
421
|
+
onEnd: (state: TimedStream) => void,
|
|
422
|
+
onStop: () => void,
|
|
423
|
+
): AsyncIterable<ComponentOutput> {
|
|
424
|
+
const state: TimedStream = {
|
|
425
|
+
iterator: stream[Symbol.asyncIterator](),
|
|
426
|
+
outputs: new Mailbox<ComponentOutput>(),
|
|
427
|
+
stopped: false,
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
void (async () => {
|
|
431
|
+
let failure: { error: unknown } | undefined;
|
|
432
|
+
try {
|
|
433
|
+
while (!state.stopped) {
|
|
434
|
+
const result = await state.iterator.next();
|
|
435
|
+
if (result.done || state.stopped) break;
|
|
436
|
+
onItem(result.value, state);
|
|
437
|
+
}
|
|
438
|
+
} catch (error) {
|
|
439
|
+
failure = { error };
|
|
440
|
+
} finally {
|
|
441
|
+
if (!state.stopped) onEnd(state);
|
|
442
|
+
state.outputs.end(failure);
|
|
443
|
+
}
|
|
444
|
+
})();
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
yield* state.outputs.drain();
|
|
448
|
+
} finally {
|
|
449
|
+
if (!state.outputs.isEnded) {
|
|
450
|
+
// The reader stopped early: no more reads of the source (deep dive DATA-12)
|
|
451
|
+
state.stopped = true;
|
|
452
|
+
onStop();
|
|
453
|
+
endSource(state.iterator);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Throttle a component stream to emit at most once per interval.
|
|
460
|
+
*
|
|
461
|
+
* The first item of an interval goes out at once. The last item that arrives during the
|
|
462
|
+
* interval goes out when the interval ends. (Before, it waited until the source ended: deep
|
|
463
|
+
* dive DATA-12.) When the source ends, a waiting item goes out at once.
|
|
464
|
+
*/
|
|
465
|
+
export function throttleStream(
|
|
466
|
+
stream: AsyncIterable<ComponentOutput>,
|
|
467
|
+
intervalMs: number
|
|
468
|
+
): AsyncIterable<ComponentOutput> {
|
|
469
|
+
let lastEmit = -Infinity;
|
|
470
|
+
let pending: { output: ComponentOutput } | null = null;
|
|
471
|
+
let timer: TimerId | null = null;
|
|
472
|
+
|
|
473
|
+
const stopTimer = (): void => {
|
|
474
|
+
if (timer !== null) {
|
|
475
|
+
clearTimer(timer);
|
|
476
|
+
timer = null;
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
return timedStream(
|
|
481
|
+
stream,
|
|
482
|
+
(output, state) => {
|
|
483
|
+
const now = Date.now();
|
|
484
|
+
if (timer === null && now - lastEmit >= intervalMs) {
|
|
485
|
+
lastEmit = now;
|
|
486
|
+
state.outputs.push(output);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
pending = { output };
|
|
490
|
+
if (timer === null) {
|
|
491
|
+
timer = setTimer(() => {
|
|
492
|
+
timer = null;
|
|
493
|
+
if (pending && !state.stopped) {
|
|
494
|
+
lastEmit = Date.now();
|
|
495
|
+
state.outputs.push(pending.output);
|
|
496
|
+
pending = null;
|
|
497
|
+
}
|
|
498
|
+
}, Math.max(0, lastEmit + intervalMs - now));
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
(state) => {
|
|
502
|
+
stopTimer();
|
|
503
|
+
if (pending) {
|
|
504
|
+
state.outputs.push(pending.output);
|
|
505
|
+
pending = null;
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
stopTimer,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Debounce a component stream to emit only after a quiet period.
|
|
514
|
+
*
|
|
515
|
+
* When the stream ends, the last output goes out at once (BUGS-2026-07 H29). When the reader
|
|
516
|
+
* stops, the source is not read further (deep dive DATA-12).
|
|
517
|
+
*/
|
|
518
|
+
export function debounceStream(
|
|
519
|
+
stream: AsyncIterable<ComponentOutput>,
|
|
520
|
+
waitMs: number
|
|
521
|
+
): AsyncIterable<ComponentOutput> {
|
|
522
|
+
let latest: { output: ComponentOutput } | null = null;
|
|
523
|
+
let timer: TimerId | null = null;
|
|
524
|
+
|
|
525
|
+
const stopTimer = (): void => {
|
|
526
|
+
if (timer !== null) {
|
|
527
|
+
clearTimer(timer);
|
|
528
|
+
timer = null;
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
return timedStream(
|
|
533
|
+
stream,
|
|
534
|
+
(output, state) => {
|
|
535
|
+
latest = { output };
|
|
536
|
+
stopTimer();
|
|
537
|
+
timer = setTimer(() => {
|
|
538
|
+
timer = null;
|
|
539
|
+
if (latest && !state.stopped) {
|
|
540
|
+
state.outputs.push(latest.output);
|
|
541
|
+
latest = null;
|
|
542
|
+
}
|
|
543
|
+
}, waitMs);
|
|
544
|
+
},
|
|
545
|
+
(state) => {
|
|
546
|
+
stopTimer();
|
|
547
|
+
if (latest) {
|
|
548
|
+
state.outputs.push(latest.output);
|
|
549
|
+
latest = null;
|
|
550
|
+
}
|
|
551
|
+
},
|
|
552
|
+
stopTimer,
|
|
553
|
+
);
|
|
554
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge Types
|
|
3
|
+
*
|
|
4
|
+
* Types for bridging splay's rendering model with client's procedure system.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ComponentOutput, Size } from "./components/index.js";
|
|
8
|
+
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// Render Context (splay-compatible)
|
|
11
|
+
// =============================================================================
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Render context passed to component renderers.
|
|
15
|
+
* Compatible with splay's RenderContext interface.
|
|
16
|
+
*/
|
|
17
|
+
export interface RenderContext<TData = unknown> {
|
|
18
|
+
/** Data to render */
|
|
19
|
+
data: TData;
|
|
20
|
+
/** Available size for rendering */
|
|
21
|
+
size: Size;
|
|
22
|
+
/** Path in the render tree */
|
|
23
|
+
path: string;
|
|
24
|
+
/** Depth in the render tree */
|
|
25
|
+
depth: number;
|
|
26
|
+
/** Render a child component */
|
|
27
|
+
render: (data: unknown, size: Size, path: string) => ComponentOutput | Promise<ComponentOutput>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Registry Interface (splay-compatible)
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Component renderer function.
|
|
36
|
+
*/
|
|
37
|
+
export type ComponentRenderer<TOutput = ComponentOutput> = (
|
|
38
|
+
ctx: RenderContext
|
|
39
|
+
) => TOutput | Promise<TOutput>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Streaming component renderer function.
|
|
43
|
+
*/
|
|
44
|
+
export type StreamingComponentRenderer<TOutput = ComponentOutput> = (
|
|
45
|
+
ctx: RenderContext
|
|
46
|
+
) => AsyncIterable<TOutput>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Registry interface compatible with splay.
|
|
50
|
+
*/
|
|
51
|
+
export interface Registry<TOutput = ComponentOutput> {
|
|
52
|
+
/** Get a renderer for a component type */
|
|
53
|
+
get(type: string): ComponentRenderer<TOutput> | undefined;
|
|
54
|
+
/** Check if a renderer exists */
|
|
55
|
+
has(type: string): boolean;
|
|
56
|
+
/** Register a renderer (throws - use client procedures instead) */
|
|
57
|
+
register(type: string, renderer: ComponentRenderer<TOutput>): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Streaming registry interface.
|
|
62
|
+
*/
|
|
63
|
+
export interface StreamingRegistry<TOutput = ComponentOutput> {
|
|
64
|
+
/** Get a streaming renderer for a component type */
|
|
65
|
+
get(type: string): StreamingComponentRenderer<TOutput> | undefined;
|
|
66
|
+
/** Check if a renderer exists */
|
|
67
|
+
has(type: string): boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// =============================================================================
|
|
71
|
+
// Client Registry Options
|
|
72
|
+
// =============================================================================
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Options for creating a client registry.
|
|
76
|
+
*/
|
|
77
|
+
export interface ClientRegistryOptions {
|
|
78
|
+
/** Namespace prefix for component lookups (e.g., "ui" → "components.ui.{type}") */
|
|
79
|
+
namespace?: string;
|
|
80
|
+
/** Default size for components without explicit size */
|
|
81
|
+
defaultSize?: Size;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Options for creating a streaming registry.
|
|
86
|
+
*/
|
|
87
|
+
export interface StreamingRegistryOptions extends ClientRegistryOptions {
|
|
88
|
+
/** Buffer size for streaming backpressure */
|
|
89
|
+
bufferSize?: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// =============================================================================
|
|
93
|
+
// Hydration Types
|
|
94
|
+
// =============================================================================
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Component map for hydration.
|
|
98
|
+
* Maps component type names to framework-specific components.
|
|
99
|
+
*/
|
|
100
|
+
export type ComponentMap<TComponent = unknown> = Record<string, TComponent>;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Hydration options.
|
|
104
|
+
*/
|
|
105
|
+
export interface HydrateOptions<TComponent = unknown> {
|
|
106
|
+
/** Map of component types to framework components */
|
|
107
|
+
components: ComponentMap<TComponent>;
|
|
108
|
+
/** Fallback component for unknown types */
|
|
109
|
+
fallback?: TComponent;
|
|
110
|
+
/** Handler for null outputs */
|
|
111
|
+
onNull?: () => TComponent | null;
|
|
112
|
+
/** Handler for fragments */
|
|
113
|
+
onFragment?: (children: TComponent[]) => TComponent;
|
|
114
|
+
}
|