@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,416 @@
1
+ import {
2
+ createRawStreamDeserializePlugin,
3
+ encode,
4
+ invariant,
5
+ isNotFound,
6
+ parseRedirect,
7
+ } from '@benjavicente/router-core'
8
+ import { fromCrossJSON, toJSONAsync } from 'seroval'
9
+ import { getDefaultSerovalPlugins } from '../getDefaultSerovalPlugins'
10
+ import {
11
+ TSS_CONTENT_TYPE_FRAMED,
12
+ TSS_FORMDATA_CONTEXT,
13
+ X_TSS_RAW_RESPONSE,
14
+ X_TSS_SERIALIZED,
15
+ validateFramedProtocolVersion,
16
+ } from '../constants'
17
+ import { createFrameDecoder } from './frame-decoder'
18
+ import type { FunctionMiddlewareClientFnOptions } from '../createMiddleware'
19
+ import type { Plugin as SerovalPlugin } from 'seroval'
20
+
21
+ let serovalPlugins: Array<SerovalPlugin<any, any>> | null = null
22
+
23
+ /**
24
+ * Checks if an object has at least one own enumerable property.
25
+ * More efficient than Object.keys(obj).length > 0 as it short-circuits on first property.
26
+ */
27
+ const hop = Object.prototype.hasOwnProperty
28
+ function hasOwnProperties(obj: object): boolean {
29
+ for (const _ in obj) {
30
+ if (hop.call(obj, _)) {
31
+ return true
32
+ }
33
+ }
34
+ return false
35
+ }
36
+ // caller =>
37
+ // serverFnFetcher =>
38
+ // client =>
39
+ // server =>
40
+ // fn =>
41
+ // seroval =>
42
+ // client middleware =>
43
+ // serverFnFetcher =>
44
+ // caller
45
+
46
+ export async function serverFnFetcher(
47
+ url: string,
48
+ args: Array<any>,
49
+ handler: (url: string, requestInit: RequestInit) => Promise<Response>,
50
+ ) {
51
+ if (!serovalPlugins) {
52
+ serovalPlugins = getDefaultSerovalPlugins()
53
+ }
54
+ const _first = args[0]
55
+
56
+ const first = _first as FunctionMiddlewareClientFnOptions<any, any, any> & {
57
+ headers?: HeadersInit
58
+ }
59
+
60
+ // Use custom fetch if provided, otherwise fall back to the passed handler (global fetch)
61
+ const fetchImpl = first.fetch ?? handler
62
+
63
+ const type = first.data instanceof FormData ? 'formData' : 'payload'
64
+
65
+ // Arrange the headers
66
+ const headers = first.headers ? new Headers(first.headers) : new Headers()
67
+ headers.set('x-tsr-serverFn', 'true')
68
+
69
+ if (type === 'payload') {
70
+ headers.set(
71
+ 'accept',
72
+ `${TSS_CONTENT_TYPE_FRAMED}, application/x-ndjson, application/json`,
73
+ )
74
+ }
75
+
76
+ // If the method is GET, we need to move the payload to the query string
77
+ if (first.method === 'GET') {
78
+ if (type === 'formData') {
79
+ throw new Error('FormData is not supported with GET requests')
80
+ }
81
+ const serializedPayload = await serializePayload(first)
82
+ if (serializedPayload !== undefined) {
83
+ const encodedPayload = encode({
84
+ payload: serializedPayload,
85
+ })
86
+ if (url.includes('?')) {
87
+ url += `&${encodedPayload}`
88
+ } else {
89
+ url += `?${encodedPayload}`
90
+ }
91
+ }
92
+ }
93
+
94
+ let body = undefined
95
+ if (first.method === 'POST') {
96
+ const fetchBody = await getFetchBody(first)
97
+ if (fetchBody?.contentType) {
98
+ headers.set('content-type', fetchBody.contentType)
99
+ }
100
+ body = fetchBody?.body
101
+ }
102
+
103
+ return await getResponse(async () =>
104
+ fetchImpl(url, {
105
+ method: first.method,
106
+ headers,
107
+ signal: first.signal,
108
+ body,
109
+ }),
110
+ )
111
+ }
112
+
113
+ async function serializePayload(
114
+ opts: FunctionMiddlewareClientFnOptions<any, any, any>,
115
+ ): Promise<string | undefined> {
116
+ let payloadAvailable = false
117
+ const payloadToSerialize: any = {}
118
+ if (opts.data !== undefined) {
119
+ payloadAvailable = true
120
+ payloadToSerialize['data'] = opts.data
121
+ }
122
+
123
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
124
+ if (opts.context && hasOwnProperties(opts.context)) {
125
+ payloadAvailable = true
126
+ payloadToSerialize['context'] = opts.context
127
+ }
128
+
129
+ if (payloadAvailable) {
130
+ return serialize(payloadToSerialize)
131
+ }
132
+ return undefined
133
+ }
134
+
135
+ async function serialize(data: any) {
136
+ return JSON.stringify(
137
+ await Promise.resolve(toJSONAsync(data, { plugins: serovalPlugins! })),
138
+ )
139
+ }
140
+
141
+ async function getFetchBody(
142
+ opts: FunctionMiddlewareClientFnOptions<any, any, any>,
143
+ ): Promise<{ body: FormData | string; contentType?: string } | undefined> {
144
+ if (opts.data instanceof FormData) {
145
+ let serializedContext = undefined
146
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
147
+ if (opts.context && hasOwnProperties(opts.context)) {
148
+ serializedContext = await serialize(opts.context)
149
+ }
150
+ if (serializedContext !== undefined) {
151
+ opts.data.set(TSS_FORMDATA_CONTEXT, serializedContext)
152
+ }
153
+ return { body: opts.data }
154
+ }
155
+ const serializedBody = await serializePayload(opts)
156
+ if (serializedBody) {
157
+ return { body: serializedBody, contentType: 'application/json' }
158
+ }
159
+ return undefined
160
+ }
161
+
162
+ /**
163
+ * Retrieves a response from a given function and manages potential errors
164
+ * and special response types including redirects and not found errors.
165
+ *
166
+ * @param fn - The function to execute for obtaining the response.
167
+ * @returns The processed response from the function.
168
+ * @throws If the response is invalid or an error occurs during processing.
169
+ */
170
+ async function getResponse(fn: () => Promise<Response>) {
171
+ let response: Response
172
+ try {
173
+ response = await fn() // client => server => fn => server => client
174
+ } catch (error) {
175
+ if (error instanceof Response) {
176
+ response = error
177
+ } else {
178
+ console.log(error)
179
+ throw error
180
+ }
181
+ }
182
+
183
+ if (response.headers.get(X_TSS_RAW_RESPONSE) === 'true') {
184
+ return response
185
+ }
186
+
187
+ const contentType = response.headers.get('content-type')
188
+ if (!contentType) {
189
+ if (process.env.NODE_ENV !== 'production') {
190
+ throw new Error(
191
+ 'Invariant failed: expected content-type header to be set',
192
+ )
193
+ }
194
+
195
+ invariant()
196
+ }
197
+ const serializedByStart = !!response.headers.get(X_TSS_SERIALIZED)
198
+
199
+ // If the response is serialized by the start server, we need to process it
200
+ // differently than a normal response.
201
+ if (serializedByStart) {
202
+ let result
203
+
204
+ // If it's a framed response (contains RawStream), use frame decoder
205
+ if (contentType.includes(TSS_CONTENT_TYPE_FRAMED)) {
206
+ // Validate protocol version compatibility
207
+ validateFramedProtocolVersion(contentType)
208
+
209
+ if (!response.body) {
210
+ throw new Error('No response body for framed response')
211
+ }
212
+
213
+ const { getOrCreateStream, jsonChunks } = createFrameDecoder(
214
+ response.body,
215
+ )
216
+
217
+ // Create deserialize plugin that wires up the raw streams
218
+ const rawStreamPlugin =
219
+ createRawStreamDeserializePlugin(getOrCreateStream)
220
+ const plugins = [rawStreamPlugin, ...(serovalPlugins || [])]
221
+
222
+ const refs = new Map()
223
+ result = await processFramedResponse({
224
+ jsonStream: jsonChunks,
225
+ onMessage: (msg: any) => fromCrossJSON(msg, { refs, plugins }),
226
+ onError(msg, error) {
227
+ console.error(msg, error)
228
+ },
229
+ })
230
+ }
231
+ // If it's a stream from the start serializer, process it as such
232
+ else if (contentType.includes('application/x-ndjson')) {
233
+ const refs = new Map()
234
+ result = await processServerFnResponse({
235
+ response,
236
+ onMessage: (msg) =>
237
+ fromCrossJSON(msg, { refs, plugins: serovalPlugins! }),
238
+ onError(msg, error) {
239
+ // TODO how could we notify consumer that an error occurred?
240
+ console.error(msg, error)
241
+ },
242
+ })
243
+ }
244
+ // If it's a JSON response, it can be simpler
245
+ else if (contentType.includes('application/json')) {
246
+ const jsonPayload = await response.json()
247
+ result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })
248
+ }
249
+
250
+ if (!result) {
251
+ if (process.env.NODE_ENV !== 'production') {
252
+ throw new Error('Invariant failed: expected result to be resolved')
253
+ }
254
+
255
+ invariant()
256
+ }
257
+ if (result instanceof Error) {
258
+ throw result
259
+ }
260
+
261
+ return result
262
+ }
263
+
264
+ // If it wasn't processed by the start serializer, check
265
+ // if it's JSON
266
+ if (contentType.includes('application/json')) {
267
+ const jsonPayload = await response.json()
268
+ const redirect = parseRedirect(jsonPayload)
269
+ if (redirect) {
270
+ throw redirect
271
+ }
272
+ if (isNotFound(jsonPayload)) {
273
+ throw jsonPayload
274
+ }
275
+ return jsonPayload
276
+ }
277
+
278
+ // Otherwise, if it's not OK, throw the content
279
+ if (!response.ok) {
280
+ throw new Error(await response.text())
281
+ }
282
+
283
+ // Or return the response itself
284
+ return response
285
+ }
286
+
287
+ async function processServerFnResponse({
288
+ response,
289
+ onMessage,
290
+ onError,
291
+ }: {
292
+ response: Response
293
+ onMessage: (msg: any) => any
294
+ onError?: (msg: string, error?: any) => void
295
+ }) {
296
+ if (!response.body) {
297
+ throw new Error('No response body')
298
+ }
299
+
300
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
301
+
302
+ let buffer = ''
303
+ let firstRead = false
304
+ let firstObject
305
+
306
+ while (!firstRead) {
307
+ const { value, done } = await reader.read()
308
+ if (value) buffer += value
309
+
310
+ if (buffer.length === 0 && done) {
311
+ throw new Error('Stream ended before first object')
312
+ }
313
+
314
+ // common case: buffer ends with newline
315
+ if (buffer.endsWith('\n')) {
316
+ const lines = buffer.split('\n').filter(Boolean)
317
+ const firstLine = lines[0]
318
+ if (!firstLine) throw new Error('No JSON line in the first chunk')
319
+ firstObject = JSON.parse(firstLine)
320
+ firstRead = true
321
+ buffer = lines.slice(1).join('\n')
322
+ } else {
323
+ // fallback: wait for a newline to parse first object safely
324
+ const newlineIndex = buffer.indexOf('\n')
325
+ if (newlineIndex >= 0) {
326
+ const line = buffer.slice(0, newlineIndex).trim()
327
+ buffer = buffer.slice(newlineIndex + 1)
328
+ if (line.length > 0) {
329
+ firstObject = JSON.parse(line)
330
+ firstRead = true
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ // process rest of the stream asynchronously
337
+ ;(async () => {
338
+ try {
339
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
340
+ while (true) {
341
+ const { value, done } = await reader.read()
342
+ if (value) buffer += value
343
+
344
+ const lastNewline = buffer.lastIndexOf('\n')
345
+ if (lastNewline >= 0) {
346
+ const chunk = buffer.slice(0, lastNewline)
347
+ buffer = buffer.slice(lastNewline + 1)
348
+ const lines = chunk.split('\n').filter(Boolean)
349
+
350
+ for (const line of lines) {
351
+ try {
352
+ onMessage(JSON.parse(line))
353
+ } catch (e) {
354
+ onError?.(`Invalid JSON line: ${line}`, e)
355
+ }
356
+ }
357
+ }
358
+
359
+ if (done) {
360
+ break
361
+ }
362
+ }
363
+ } catch (err) {
364
+ onError?.('Stream processing error:', err)
365
+ }
366
+ })()
367
+
368
+ return onMessage(firstObject)
369
+ }
370
+
371
+ /**
372
+ * Processes a framed response where each JSON chunk is a complete JSON string
373
+ * (already decoded by frame decoder).
374
+ */
375
+ async function processFramedResponse({
376
+ jsonStream,
377
+ onMessage,
378
+ onError,
379
+ }: {
380
+ jsonStream: ReadableStream<string>
381
+ onMessage: (msg: any) => any
382
+ onError?: (msg: string, error?: any) => void
383
+ }) {
384
+ const reader = jsonStream.getReader()
385
+
386
+ // Read first JSON frame - this is the main result
387
+ const { value: firstValue, done: firstDone } = await reader.read()
388
+ if (firstDone || !firstValue) {
389
+ throw new Error('Stream ended before first object')
390
+ }
391
+
392
+ // Each frame is a complete JSON string
393
+ const firstObject = JSON.parse(firstValue)
394
+
395
+ // Process remaining frames asynchronously (for streaming refs like RawStream)
396
+ ;(async () => {
397
+ try {
398
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
399
+ while (true) {
400
+ const { value, done } = await reader.read()
401
+ if (done) break
402
+ if (value) {
403
+ try {
404
+ onMessage(JSON.parse(value))
405
+ } catch (e) {
406
+ onError?.(`Invalid JSON: ${value}`, e)
407
+ }
408
+ }
409
+ }
410
+ } catch (err) {
411
+ onError?.('Stream processing error:', err)
412
+ }
413
+ })()
414
+
415
+ return onMessage(firstObject)
416
+ }
@@ -0,0 +1,90 @@
1
+ export const TSS_FORMDATA_CONTEXT = '__TSS_CONTEXT'
2
+ export const TSS_SERVER_FUNCTION = Symbol.for('TSS_SERVER_FUNCTION')
3
+ export const TSS_SERVER_FUNCTION_FACTORY = Symbol.for(
4
+ 'TSS_SERVER_FUNCTION_FACTORY',
5
+ )
6
+
7
+ export const X_TSS_SERIALIZED = 'x-tss-serialized'
8
+ export const X_TSS_RAW_RESPONSE = 'x-tss-raw'
9
+ export const X_TSS_CONTEXT = 'x-tss-context'
10
+
11
+ /** Content-Type for multiplexed framed responses (RawStream support) */
12
+ export const TSS_CONTENT_TYPE_FRAMED = 'application/x-tss-framed'
13
+
14
+ /**
15
+ * Frame types for binary multiplexing protocol.
16
+ */
17
+ export const FrameType = {
18
+ /** Seroval JSON chunk (NDJSON line) */
19
+ JSON: 0,
20
+ /** Raw stream data chunk */
21
+ CHUNK: 1,
22
+ /** Raw stream end (EOF) */
23
+ END: 2,
24
+ /** Raw stream error */
25
+ ERROR: 3,
26
+ } as const
27
+
28
+ export type FrameType = (typeof FrameType)[keyof typeof FrameType]
29
+
30
+ /** Header size in bytes: type(1) + streamId(4) + length(4) */
31
+ export const FRAME_HEADER_SIZE = 9
32
+
33
+ /** Current protocol version for framed responses */
34
+ export const TSS_FRAMED_PROTOCOL_VERSION = 1
35
+
36
+ /** Full Content-Type header value with version parameter */
37
+ export const TSS_CONTENT_TYPE_FRAMED_VERSIONED = `${TSS_CONTENT_TYPE_FRAMED}; v=${TSS_FRAMED_PROTOCOL_VERSION}`
38
+
39
+ /**
40
+ * Parses the version parameter from a framed Content-Type header.
41
+ * Returns undefined if no version parameter is present.
42
+ */
43
+ const FRAMED_VERSION_REGEX = /;\s*v=(\d+)/
44
+ export function parseFramedProtocolVersion(
45
+ contentType: string,
46
+ ): number | undefined {
47
+ // Match "v=<number>" in the content-type parameters
48
+ const match = contentType.match(FRAMED_VERSION_REGEX)
49
+ return match ? parseInt(match[1]!, 10) : undefined
50
+ }
51
+
52
+ /**
53
+ * Validates that the server's protocol version is compatible with this client.
54
+ * Throws an error if versions are incompatible.
55
+ */
56
+ export function validateFramedProtocolVersion(contentType: string): void {
57
+ const serverVersion = parseFramedProtocolVersion(contentType)
58
+ if (serverVersion === undefined) {
59
+ // No version specified - assume compatible (backwards compat)
60
+ return
61
+ }
62
+ if (serverVersion !== TSS_FRAMED_PROTOCOL_VERSION) {
63
+ throw new Error(
64
+ `Incompatible framed protocol version: server=${serverVersion}, client=${TSS_FRAMED_PROTOCOL_VERSION}. ` +
65
+ `Please ensure client and server are using compatible versions.`,
66
+ )
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Minimal metadata about a server function, available to client middleware.
72
+ * Only contains the function ID since name/filename may expose server internals.
73
+ */
74
+ export interface ClientFnMeta {
75
+ /** The unique identifier for this server function */
76
+ id: string
77
+ }
78
+
79
+ /**
80
+ * Full metadata about a server function, available to server middleware and server functions.
81
+ * This information is embedded at compile time by the TanStack Start compiler.
82
+ */
83
+ export interface ServerFnMeta extends ClientFnMeta {
84
+ /** The original variable name of the server function (e.g., "myServerFn") */
85
+ name: string
86
+ /** The source file path relative to the project root (e.g., "src/routes/api.ts") */
87
+ filename: string
88
+ }
89
+
90
+ export {}