@benjavicente/start-client-core 1.167.9

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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -0
  3. package/bin/intent.js +25 -0
  4. package/dist/esm/client/ServerFunctionSerializationAdapter.d.ts +7 -0
  5. package/dist/esm/client/ServerFunctionSerializationAdapter.js +18 -0
  6. package/dist/esm/client/ServerFunctionSerializationAdapter.js.map +1 -0
  7. package/dist/esm/client/hydrateStart.d.ts +2 -0
  8. package/dist/esm/client/hydrateStart.js +31 -0
  9. package/dist/esm/client/hydrateStart.js.map +1 -0
  10. package/dist/esm/client/index.d.ts +2 -0
  11. package/dist/esm/client/index.js +2 -0
  12. package/dist/esm/client-rpc/createClientRpc.d.ts +6 -0
  13. package/dist/esm/client-rpc/createClientRpc.js +21 -0
  14. package/dist/esm/client-rpc/createClientRpc.js.map +1 -0
  15. package/dist/esm/client-rpc/frame-decoder.d.ts +23 -0
  16. package/dist/esm/client-rpc/frame-decoder.js +231 -0
  17. package/dist/esm/client-rpc/frame-decoder.js.map +1 -0
  18. package/dist/esm/client-rpc/index.d.ts +1 -0
  19. package/dist/esm/client-rpc/index.js +2 -0
  20. package/dist/esm/client-rpc/serverFnFetcher.d.ts +1 -0
  21. package/dist/esm/client-rpc/serverFnFetcher.js +231 -0
  22. package/dist/esm/client-rpc/serverFnFetcher.js.map +1 -0
  23. package/dist/esm/constants.d.ts +53 -0
  24. package/dist/esm/constants.js +46 -0
  25. package/dist/esm/constants.js.map +1 -0
  26. package/dist/esm/createMiddleware.d.ts +195 -0
  27. package/dist/esm/createMiddleware.js +26 -0
  28. package/dist/esm/createMiddleware.js.map +1 -0
  29. package/dist/esm/createServerFn.d.ts +131 -0
  30. package/dist/esm/createServerFn.js +200 -0
  31. package/dist/esm/createServerFn.js.map +1 -0
  32. package/dist/esm/createStart.d.ts +50 -0
  33. package/dist/esm/createStart.js +29 -0
  34. package/dist/esm/createStart.js.map +1 -0
  35. package/dist/esm/fake-start-entry.d.ts +2 -0
  36. package/dist/esm/fake-start-entry.js +7 -0
  37. package/dist/esm/fake-start-entry.js.map +1 -0
  38. package/dist/esm/getDefaultSerovalPlugins.d.ts +1 -0
  39. package/dist/esm/getDefaultSerovalPlugins.js +10 -0
  40. package/dist/esm/getDefaultSerovalPlugins.js.map +1 -0
  41. package/dist/esm/getGlobalStartContext.d.ts +3 -0
  42. package/dist/esm/getGlobalStartContext.js +12 -0
  43. package/dist/esm/getGlobalStartContext.js.map +1 -0
  44. package/dist/esm/getRouterInstance.d.ts +2 -0
  45. package/dist/esm/getRouterInstance.js +8 -0
  46. package/dist/esm/getRouterInstance.js.map +1 -0
  47. package/dist/esm/getStartContextServerOnly.d.ts +2 -0
  48. package/dist/esm/getStartContextServerOnly.js +8 -0
  49. package/dist/esm/getStartContextServerOnly.js.map +1 -0
  50. package/dist/esm/getStartOptions.d.ts +2 -0
  51. package/dist/esm/getStartOptions.js +8 -0
  52. package/dist/esm/getStartOptions.js.map +1 -0
  53. package/dist/esm/global.d.ts +7 -0
  54. package/dist/esm/index.d.ts +20 -0
  55. package/dist/esm/index.js +12 -0
  56. package/dist/esm/safeObjectMerge.d.ts +10 -0
  57. package/dist/esm/safeObjectMerge.js +30 -0
  58. package/dist/esm/safeObjectMerge.js.map +1 -0
  59. package/dist/esm/serverRoute.d.ts +65 -0
  60. package/dist/esm/startEntry.d.ts +8 -0
  61. package/dist/esm/tests/createServerFn.test-d.d.ts +1 -0
  62. package/dist/esm/tests/createServerMiddleware.test-d.d.ts +1 -0
  63. package/package.json +98 -0
  64. package/skills/start-core/SKILL.md +210 -0
  65. package/skills/start-core/deployment/SKILL.md +306 -0
  66. package/skills/start-core/execution-model/SKILL.md +302 -0
  67. package/skills/start-core/middleware/SKILL.md +365 -0
  68. package/skills/start-core/server-functions/SKILL.md +335 -0
  69. package/skills/start-core/server-routes/SKILL.md +280 -0
  70. package/src/client/ServerFunctionSerializationAdapter.ts +16 -0
  71. package/src/client/hydrateStart.ts +43 -0
  72. package/src/client/index.ts +2 -0
  73. package/src/client-rpc/createClientRpc.ts +20 -0
  74. package/src/client-rpc/frame-decoder.ts +389 -0
  75. package/src/client-rpc/index.ts +1 -0
  76. package/src/client-rpc/serverFnFetcher.ts +416 -0
  77. package/src/constants.ts +90 -0
  78. package/src/createMiddleware.ts +824 -0
  79. package/src/createServerFn.ts +813 -0
  80. package/src/createStart.ts +166 -0
  81. package/src/fake-start-entry.ts +2 -0
  82. package/src/getDefaultSerovalPlugins.ts +17 -0
  83. package/src/getGlobalStartContext.ts +18 -0
  84. package/src/getRouterInstance.ts +8 -0
  85. package/src/getStartContextServerOnly.ts +4 -0
  86. package/src/getStartOptions.ts +8 -0
  87. package/src/global.ts +9 -0
  88. package/src/index.tsx +119 -0
  89. package/src/safeObjectMerge.ts +38 -0
  90. package/src/serverRoute.ts +509 -0
  91. package/src/start-entry.d.ts +11 -0
  92. package/src/startEntry.ts +10 -0
  93. package/src/tests/createServerFn.test-d.ts +866 -0
  94. package/src/tests/createServerMiddleware.test-d.ts +810 -0
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Client-side frame decoder for multiplexed responses.
3
+ *
4
+ * Decodes binary frame protocol and reconstructs:
5
+ * - JSON stream (NDJSON lines for seroval)
6
+ * - Raw streams (binary data as ReadableStream<Uint8Array>)
7
+ */
8
+
9
+ import { FRAME_HEADER_SIZE, FrameType } from '../constants'
10
+
11
+ /** Cached TextDecoder for frame decoding */
12
+ const textDecoder = new TextDecoder()
13
+
14
+ /** Shared empty buffer for empty buffer case - avoids allocation */
15
+ const EMPTY_BUFFER = new Uint8Array(0)
16
+
17
+ /** Hardening limits to prevent memory/CPU DoS */
18
+ const MAX_FRAME_PAYLOAD_SIZE = 16 * 1024 * 1024 // 16MiB
19
+ const MAX_BUFFERED_BYTES = 32 * 1024 * 1024 // 32MiB
20
+ const MAX_STREAMS = 1024
21
+ const MAX_FRAMES = 100_000 // Limit total frames to prevent CPU DoS
22
+
23
+ /**
24
+ * Result of frame decoding.
25
+ */
26
+ export interface FrameDecoderResult {
27
+ /** Gets or creates a raw stream by ID (for use by deserialize plugin) */
28
+ getOrCreateStream: (id: number) => ReadableStream<Uint8Array>
29
+ /** Stream of JSON strings (NDJSON lines) */
30
+ jsonChunks: ReadableStream<string>
31
+ }
32
+
33
+ /**
34
+ * Creates a frame decoder that processes a multiplexed response stream.
35
+ *
36
+ * @param input The raw response body stream
37
+ * @returns Decoded JSON stream and stream getter function
38
+ */
39
+ export function createFrameDecoder(
40
+ input: ReadableStream<Uint8Array>,
41
+ ): FrameDecoderResult {
42
+ const streamControllers = new Map<
43
+ number,
44
+ ReadableStreamDefaultController<Uint8Array>
45
+ >()
46
+ const streams = new Map<number, ReadableStream<Uint8Array>>()
47
+ const cancelledStreamIds = new Set<number>()
48
+
49
+ let cancelled = false as boolean
50
+ let inputReader: ReadableStreamReader<Uint8Array> | null = null
51
+ let frameCount = 0
52
+
53
+ let jsonController!: ReadableStreamDefaultController<string>
54
+ const jsonChunks = new ReadableStream<string>({
55
+ start(controller) {
56
+ jsonController = controller
57
+ },
58
+ cancel() {
59
+ cancelled = true
60
+ try {
61
+ inputReader?.cancel()
62
+ } catch {
63
+ // Ignore
64
+ }
65
+
66
+ streamControllers.forEach((ctrl) => {
67
+ try {
68
+ ctrl.error(new Error('Framed response cancelled'))
69
+ } catch {
70
+ // Ignore
71
+ }
72
+ })
73
+ streamControllers.clear()
74
+ streams.clear()
75
+ cancelledStreamIds.clear()
76
+ },
77
+ })
78
+
79
+ /**
80
+ * Gets or creates a stream for a given stream ID.
81
+ * Called by deserialize plugin when it encounters a RawStream reference.
82
+ */
83
+ function getOrCreateStream(id: number): ReadableStream<Uint8Array> {
84
+ const existing = streams.get(id)
85
+ if (existing) {
86
+ return existing
87
+ }
88
+
89
+ // If we already received an END/ERROR for this streamId, returning a fresh stream
90
+ // would hang consumers. Return an already-closed stream instead.
91
+ if (cancelledStreamIds.has(id)) {
92
+ return new ReadableStream<Uint8Array>({
93
+ start(controller) {
94
+ controller.close()
95
+ },
96
+ })
97
+ }
98
+
99
+ if (streams.size >= MAX_STREAMS) {
100
+ throw new Error(
101
+ `Too many raw streams in framed response (max ${MAX_STREAMS})`,
102
+ )
103
+ }
104
+
105
+ const stream = new ReadableStream<Uint8Array>({
106
+ start(ctrl) {
107
+ streamControllers.set(id, ctrl)
108
+ },
109
+ cancel() {
110
+ cancelledStreamIds.add(id)
111
+ streamControllers.delete(id)
112
+ streams.delete(id)
113
+ },
114
+ })
115
+ streams.set(id, stream)
116
+ return stream
117
+ }
118
+
119
+ /**
120
+ * Ensures stream exists and returns its controller for enqueuing data.
121
+ * Used for CHUNK frames where we need to ensure stream is created.
122
+ */
123
+ function ensureController(
124
+ id: number,
125
+ ): ReadableStreamDefaultController<Uint8Array> | undefined {
126
+ getOrCreateStream(id)
127
+ return streamControllers.get(id)
128
+ }
129
+
130
+ // Process frames asynchronously
131
+ ;(async () => {
132
+ const reader = input.getReader()
133
+ inputReader = reader
134
+
135
+ const bufferList: Array<Uint8Array> = []
136
+ let totalLength = 0
137
+
138
+ /**
139
+ * Reads header bytes from buffer chunks without flattening.
140
+ * Returns header data or null if not enough bytes available.
141
+ */
142
+ function readHeader(): {
143
+ type: number
144
+ streamId: number
145
+ length: number
146
+ } | null {
147
+ if (totalLength < FRAME_HEADER_SIZE) return null
148
+
149
+ const first = bufferList[0]!
150
+
151
+ // Fast path: header fits entirely in first chunk (common case)
152
+ if (first.length >= FRAME_HEADER_SIZE) {
153
+ const type = first[0]!
154
+ const streamId =
155
+ ((first[1]! << 24) |
156
+ (first[2]! << 16) |
157
+ (first[3]! << 8) |
158
+ first[4]!) >>>
159
+ 0
160
+ const length =
161
+ ((first[5]! << 24) |
162
+ (first[6]! << 16) |
163
+ (first[7]! << 8) |
164
+ first[8]!) >>>
165
+ 0
166
+ return { type, streamId, length }
167
+ }
168
+
169
+ // Slow path: header spans multiple chunks - flatten header bytes only
170
+ const headerBytes = new Uint8Array(FRAME_HEADER_SIZE)
171
+ let offset = 0
172
+ let remaining = FRAME_HEADER_SIZE
173
+ for (let i = 0; i < bufferList.length && remaining > 0; i++) {
174
+ const chunk = bufferList[i]!
175
+ const toCopy = Math.min(chunk.length, remaining)
176
+ headerBytes.set(chunk.subarray(0, toCopy), offset)
177
+ offset += toCopy
178
+ remaining -= toCopy
179
+ }
180
+
181
+ const type = headerBytes[0]!
182
+ const streamId =
183
+ ((headerBytes[1]! << 24) |
184
+ (headerBytes[2]! << 16) |
185
+ (headerBytes[3]! << 8) |
186
+ headerBytes[4]!) >>>
187
+ 0
188
+ const length =
189
+ ((headerBytes[5]! << 24) |
190
+ (headerBytes[6]! << 16) |
191
+ (headerBytes[7]! << 8) |
192
+ headerBytes[8]!) >>>
193
+ 0
194
+
195
+ return { type, streamId, length }
196
+ }
197
+
198
+ /**
199
+ * Flattens buffer list into single Uint8Array and removes from list.
200
+ */
201
+ function extractFlattened(count: number): Uint8Array {
202
+ if (count === 0) return EMPTY_BUFFER
203
+
204
+ const result = new Uint8Array(count)
205
+ let offset = 0
206
+ let remaining = count
207
+
208
+ while (remaining > 0 && bufferList.length > 0) {
209
+ const chunk = bufferList[0]
210
+ if (!chunk) break
211
+ const toCopy = Math.min(chunk.length, remaining)
212
+ result.set(chunk.subarray(0, toCopy), offset)
213
+
214
+ offset += toCopy
215
+ remaining -= toCopy
216
+
217
+ if (toCopy === chunk.length) {
218
+ bufferList.shift()
219
+ } else {
220
+ bufferList[0] = chunk.subarray(toCopy)
221
+ }
222
+ }
223
+
224
+ totalLength -= count
225
+ return result
226
+ }
227
+
228
+ try {
229
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
230
+ while (true) {
231
+ const { done, value } = await reader.read()
232
+ if (cancelled) break
233
+ if (done) break
234
+
235
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
236
+ if (!value) continue
237
+
238
+ // Append incoming chunk to buffer list
239
+ if (totalLength + value.length > MAX_BUFFERED_BYTES) {
240
+ throw new Error(
241
+ `Framed response buffer exceeded ${MAX_BUFFERED_BYTES} bytes`,
242
+ )
243
+ }
244
+ bufferList.push(value)
245
+ totalLength += value.length
246
+
247
+ // Parse complete frames from buffer
248
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
249
+ while (true) {
250
+ const header = readHeader()
251
+ if (!header) break // Not enough bytes for header
252
+
253
+ const { type, streamId, length } = header
254
+
255
+ if (
256
+ type !== FrameType.JSON &&
257
+ type !== FrameType.CHUNK &&
258
+ type !== FrameType.END &&
259
+ type !== FrameType.ERROR
260
+ ) {
261
+ throw new Error(`Unknown frame type: ${type}`)
262
+ }
263
+
264
+ // Enforce stream id conventions: JSON uses streamId 0, raw streams use non-zero ids
265
+ if (type === FrameType.JSON) {
266
+ if (streamId !== 0) {
267
+ throw new Error('Invalid JSON frame streamId (expected 0)')
268
+ }
269
+ } else {
270
+ if (streamId === 0) {
271
+ throw new Error('Invalid raw frame streamId (expected non-zero)')
272
+ }
273
+ }
274
+
275
+ if (length > MAX_FRAME_PAYLOAD_SIZE) {
276
+ throw new Error(
277
+ `Frame payload too large: ${length} bytes (max ${MAX_FRAME_PAYLOAD_SIZE})`,
278
+ )
279
+ }
280
+
281
+ const frameSize = FRAME_HEADER_SIZE + length
282
+ if (totalLength < frameSize) break // Wait for more data
283
+
284
+ if (++frameCount > MAX_FRAMES) {
285
+ throw new Error(
286
+ `Too many frames in framed response (max ${MAX_FRAMES})`,
287
+ )
288
+ }
289
+
290
+ // Extract and consume header bytes
291
+ extractFlattened(FRAME_HEADER_SIZE)
292
+
293
+ // Extract payload
294
+ const payload = extractFlattened(length)
295
+
296
+ // Process frame by type
297
+ switch (type) {
298
+ case FrameType.JSON: {
299
+ try {
300
+ jsonController.enqueue(textDecoder.decode(payload))
301
+ } catch {
302
+ // JSON stream may be cancelled/closed
303
+ }
304
+ break
305
+ }
306
+
307
+ case FrameType.CHUNK: {
308
+ const ctrl = ensureController(streamId)
309
+ if (ctrl) {
310
+ ctrl.enqueue(payload)
311
+ }
312
+ break
313
+ }
314
+
315
+ case FrameType.END: {
316
+ const ctrl = ensureController(streamId)
317
+ cancelledStreamIds.add(streamId)
318
+ if (ctrl) {
319
+ try {
320
+ ctrl.close()
321
+ } catch {
322
+ // Already closed
323
+ }
324
+ streamControllers.delete(streamId)
325
+ }
326
+ break
327
+ }
328
+
329
+ case FrameType.ERROR: {
330
+ const ctrl = ensureController(streamId)
331
+ cancelledStreamIds.add(streamId)
332
+ if (ctrl) {
333
+ const message = textDecoder.decode(payload)
334
+ ctrl.error(new Error(message))
335
+ streamControllers.delete(streamId)
336
+ }
337
+ break
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ if (totalLength !== 0) {
344
+ throw new Error('Incomplete frame at end of framed response')
345
+ }
346
+
347
+ // Close JSON stream when done
348
+ try {
349
+ jsonController.close()
350
+ } catch {
351
+ // JSON stream may be cancelled/closed
352
+ }
353
+
354
+ // Close any remaining streams (shouldn't happen in normal operation)
355
+ streamControllers.forEach((ctrl) => {
356
+ try {
357
+ ctrl.close()
358
+ } catch {
359
+ // Already closed
360
+ }
361
+ })
362
+ streamControllers.clear()
363
+ } catch (error) {
364
+ // Error reading - propagate to all streams
365
+ try {
366
+ jsonController.error(error)
367
+ } catch {
368
+ // Already errored/closed
369
+ }
370
+ streamControllers.forEach((ctrl) => {
371
+ try {
372
+ ctrl.error(error)
373
+ } catch {
374
+ // Already errored/closed
375
+ }
376
+ })
377
+ streamControllers.clear()
378
+ } finally {
379
+ try {
380
+ reader.releaseLock()
381
+ } catch {
382
+ // Ignore
383
+ }
384
+ inputReader = null
385
+ }
386
+ })()
387
+
388
+ return { getOrCreateStream, jsonChunks }
389
+ }
@@ -0,0 +1 @@
1
+ export { createClientRpc } from './createClientRpc'