@nordcraft/runtime 1.0.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 (168) hide show
  1. package/README.md +5 -0
  2. package/dist/api/createAPI.d.ts +20 -0
  3. package/dist/api/createAPI.js +319 -0
  4. package/dist/api/createAPI.js.map +1 -0
  5. package/dist/api/createAPIv2.d.ts +7 -0
  6. package/dist/api/createAPIv2.js +686 -0
  7. package/dist/api/createAPIv2.js.map +1 -0
  8. package/dist/components/createComponent.d.ts +13 -0
  9. package/dist/components/createComponent.js +216 -0
  10. package/dist/components/createComponent.js.map +1 -0
  11. package/dist/components/createElement.d.ts +3 -0
  12. package/dist/components/createElement.js +208 -0
  13. package/dist/components/createElement.js.map +1 -0
  14. package/dist/components/createNode.d.ts +22 -0
  15. package/dist/components/createNode.js +272 -0
  16. package/dist/components/createNode.js.map +1 -0
  17. package/dist/components/createSlot.d.ts +3 -0
  18. package/dist/components/createSlot.js +49 -0
  19. package/dist/components/createSlot.js.map +1 -0
  20. package/dist/components/createText.d.ts +23 -0
  21. package/dist/components/createText.js +68 -0
  22. package/dist/components/createText.js.map +1 -0
  23. package/dist/components/createText.test.d.ts +1 -0
  24. package/dist/components/createText.test.js +113 -0
  25. package/dist/components/createText.test.js.map +1 -0
  26. package/dist/components/renderComponent.d.ts +34 -0
  27. package/dist/components/renderComponent.js +66 -0
  28. package/dist/components/renderComponent.js.map +1 -0
  29. package/dist/context/isContextProvider.d.ts +2 -0
  30. package/dist/context/isContextProvider.js +5 -0
  31. package/dist/context/isContextProvider.js.map +1 -0
  32. package/dist/context/subscribeToContext.d.ts +4 -0
  33. package/dist/context/subscribeToContext.js +93 -0
  34. package/dist/context/subscribeToContext.js.map +1 -0
  35. package/dist/custom-components/components.d.ts +1 -0
  36. package/dist/custom-components/components.js +2 -0
  37. package/dist/custom-components/components.js.map +1 -0
  38. package/dist/custom-components/toddle-portal.d.ts +6 -0
  39. package/dist/custom-components/toddle-portal.js +20 -0
  40. package/dist/custom-components/toddle-portal.js.map +1 -0
  41. package/dist/custom-element/ToddleComponent.d.ts +37 -0
  42. package/dist/custom-element/ToddleComponent.js +244 -0
  43. package/dist/custom-element/ToddleComponent.js.map +1 -0
  44. package/dist/custom-element/defineComponents.d.ts +26 -0
  45. package/dist/custom-element/defineComponents.js +42 -0
  46. package/dist/custom-element/defineComponents.js.map +1 -0
  47. package/dist/custom-element.main.d.ts +3 -0
  48. package/dist/custom-element.main.esm.js +266 -0
  49. package/dist/custom-element.main.esm.js.map +7 -0
  50. package/dist/custom-element.main.js +14 -0
  51. package/dist/custom-element.main.js.map +1 -0
  52. package/dist/debug/logState.d.ts +4 -0
  53. package/dist/debug/logState.js +19 -0
  54. package/dist/debug/logState.js.map +1 -0
  55. package/dist/editor/drag-drop/dragEnded.d.ts +2 -0
  56. package/dist/editor/drag-drop/dragEnded.js +56 -0
  57. package/dist/editor/drag-drop/dragEnded.js.map +1 -0
  58. package/dist/editor/drag-drop/dragMove.d.ts +3 -0
  59. package/dist/editor/drag-drop/dragMove.js +74 -0
  60. package/dist/editor/drag-drop/dragMove.js.map +1 -0
  61. package/dist/editor/drag-drop/dragReorder.d.ts +3 -0
  62. package/dist/editor/drag-drop/dragReorder.js +92 -0
  63. package/dist/editor/drag-drop/dragReorder.js.map +1 -0
  64. package/dist/editor/drag-drop/dragStarted.d.ts +9 -0
  65. package/dist/editor/drag-drop/dragStarted.js +100 -0
  66. package/dist/editor/drag-drop/dragStarted.js.map +1 -0
  67. package/dist/editor/drag-drop/dropHighlight.d.ts +16 -0
  68. package/dist/editor/drag-drop/dropHighlight.js +50 -0
  69. package/dist/editor/drag-drop/dropHighlight.js.map +1 -0
  70. package/dist/editor/drag-drop/getInsertAreas.d.ts +20 -0
  71. package/dist/editor/drag-drop/getInsertAreas.js +220 -0
  72. package/dist/editor/drag-drop/getInsertAreas.js.map +1 -0
  73. package/dist/editor-preview.main.d.ts +19 -0
  74. package/dist/editor-preview.main.js +1303 -0
  75. package/dist/editor-preview.main.js.map +1 -0
  76. package/dist/events/handleAction.d.ts +3 -0
  77. package/dist/events/handleAction.js +307 -0
  78. package/dist/events/handleAction.js.map +1 -0
  79. package/dist/page.main.d.ts +7 -0
  80. package/dist/page.main.esm.js +8 -0
  81. package/dist/page.main.esm.js.map +7 -0
  82. package/dist/page.main.js +395 -0
  83. package/dist/page.main.js.map +1 -0
  84. package/dist/signal/signal.d.ts +19 -0
  85. package/dist/signal/signal.js +65 -0
  86. package/dist/signal/signal.js.map +1 -0
  87. package/dist/styles/style.d.ts +4 -0
  88. package/dist/styles/style.js +196 -0
  89. package/dist/styles/style.js.map +1 -0
  90. package/dist/utils/BatchQueue.d.ts +10 -0
  91. package/dist/utils/BatchQueue.js +25 -0
  92. package/dist/utils/BatchQueue.js.map +1 -0
  93. package/dist/utils/createFormulaCache.d.ts +3 -0
  94. package/dist/utils/createFormulaCache.js +81 -0
  95. package/dist/utils/createFormulaCache.js.map +1 -0
  96. package/dist/utils/findNearestLine.d.ts +13 -0
  97. package/dist/utils/findNearestLine.js +74 -0
  98. package/dist/utils/findNearestLine.js.map +1 -0
  99. package/dist/utils/findNearestLine.test.d.ts +1 -0
  100. package/dist/utils/findNearestLine.test.js +59 -0
  101. package/dist/utils/findNearestLine.test.js.map +1 -0
  102. package/dist/utils/getDragData.d.ts +1 -0
  103. package/dist/utils/getDragData.js +10 -0
  104. package/dist/utils/getDragData.js.map +1 -0
  105. package/dist/utils/getElementTagName.d.ts +3 -0
  106. package/dist/utils/getElementTagName.js +7 -0
  107. package/dist/utils/getElementTagName.js.map +1 -0
  108. package/dist/utils/nodes.d.ts +21 -0
  109. package/dist/utils/nodes.js +89 -0
  110. package/dist/utils/nodes.js.map +1 -0
  111. package/dist/utils/omitStyle.d.ts +2 -0
  112. package/dist/utils/omitStyle.js +13 -0
  113. package/dist/utils/omitStyle.js.map +1 -0
  114. package/dist/utils/rectHasPoint.d.ts +2 -0
  115. package/dist/utils/rectHasPoint.js +4 -0
  116. package/dist/utils/rectHasPoint.js.map +1 -0
  117. package/dist/utils/setAttribute.d.ts +4 -0
  118. package/dist/utils/setAttribute.js +57 -0
  119. package/dist/utils/setAttribute.js.map +1 -0
  120. package/dist/utils/tryStartViewTransition.d.ts +5 -0
  121. package/dist/utils/tryStartViewTransition.js +14 -0
  122. package/dist/utils/tryStartViewTransition.js.map +1 -0
  123. package/dist/utils/url.d.ts +2 -0
  124. package/dist/utils/url.js +36 -0
  125. package/dist/utils/url.js.map +1 -0
  126. package/package.json +25 -0
  127. package/src/api/createAPI.ts +375 -0
  128. package/src/api/createAPIv2.ts +931 -0
  129. package/src/components/createComponent.ts +280 -0
  130. package/src/components/createElement.ts +240 -0
  131. package/src/components/createNode.ts +381 -0
  132. package/src/components/createSlot.ts +61 -0
  133. package/src/components/createText.test.ts +117 -0
  134. package/src/components/createText.ts +104 -0
  135. package/src/components/renderComponent.ts +145 -0
  136. package/src/context/isContextProvider.ts +12 -0
  137. package/src/context/subscribeToContext.ts +135 -0
  138. package/src/custom-components/components.ts +1 -0
  139. package/src/custom-components/toddle-portal.ts +19 -0
  140. package/src/custom-element/ToddleComponent.ts +315 -0
  141. package/src/custom-element/defineComponents.ts +65 -0
  142. package/src/custom-element.main.ts +24 -0
  143. package/src/debug/logState.ts +30 -0
  144. package/src/editor/drag-drop/dragEnded.ts +75 -0
  145. package/src/editor/drag-drop/dragMove.ts +95 -0
  146. package/src/editor/drag-drop/dragReorder.ts +137 -0
  147. package/src/editor/drag-drop/dragStarted.ts +145 -0
  148. package/src/editor/drag-drop/dropHighlight.ts +82 -0
  149. package/src/editor/drag-drop/getInsertAreas.ts +235 -0
  150. package/src/editor/types.d.ts +36 -0
  151. package/src/editor-preview.main.ts +1782 -0
  152. package/src/events/handleAction.ts +387 -0
  153. package/src/page.main.ts +489 -0
  154. package/src/signal/signal.ts +74 -0
  155. package/src/styles/style.ts +254 -0
  156. package/src/types.d.ts +93 -0
  157. package/src/utils/BatchQueue.ts +24 -0
  158. package/src/utils/createFormulaCache.ts +96 -0
  159. package/src/utils/findNearestLine.test.ts +65 -0
  160. package/src/utils/findNearestLine.ts +92 -0
  161. package/src/utils/getDragData.ts +11 -0
  162. package/src/utils/getElementTagName.ts +14 -0
  163. package/src/utils/nodes.ts +125 -0
  164. package/src/utils/omitStyle.ts +19 -0
  165. package/src/utils/rectHasPoint.ts +5 -0
  166. package/src/utils/setAttribute.ts +56 -0
  167. package/src/utils/tryStartViewTransition.ts +32 -0
  168. package/src/utils/url.ts +45 -0
@@ -0,0 +1,931 @@
1
+ /* eslint-disable @typescript-eslint/no-floating-promises */
2
+ import {
3
+ createApiEvent,
4
+ createApiRequest,
5
+ isApiError,
6
+ requestHash,
7
+ } from '@nordcraft/core/dist/api/api'
8
+ import type {
9
+ ApiPerformance,
10
+ ApiRequest,
11
+ ApiStatus,
12
+ ToddleRequestInit,
13
+ } from '@nordcraft/core/dist/api/apiTypes'
14
+ import {
15
+ isEventStreamHeader,
16
+ isImageHeader,
17
+ isJsonHeader,
18
+ isJsonStreamHeader,
19
+ isTextHeader,
20
+ } from '@nordcraft/core/dist/api/headers'
21
+ import type { ActionModel } from '@nordcraft/core/dist/component/component.types'
22
+ import type {
23
+ Formula,
24
+ FormulaContext,
25
+ ValueOperationValue,
26
+ } from '@nordcraft/core/dist/formula/formula'
27
+ import { applyFormula } from '@nordcraft/core/dist/formula/formula'
28
+ import type { NestedOmit, RequireFields } from '@nordcraft/core/dist/types'
29
+ import {
30
+ omitPaths,
31
+ sortObjectEntries,
32
+ } from '@nordcraft/core/dist/utils/collections'
33
+ import { PROXY_URL_HEADER, validateUrl } from '@nordcraft/core/dist/utils/url'
34
+ import { handleAction } from '../events/handleAction'
35
+ import type { Signal } from '../signal/signal'
36
+ import type { ComponentContext, ContextApi } from '../types'
37
+
38
+ /**
39
+ * Set up an api v2 for a component.
40
+ */
41
+ export function createAPI(
42
+ apiRequest: ApiRequest,
43
+ ctx: ComponentContext,
44
+ ): RequireFields<ContextApi, 'update' | 'triggerActions'> {
45
+ // If `__toddle` isn't found it is in a web component context. We behave as if the page isn't loaded.
46
+ let timer: any = null
47
+ let api = { ...apiRequest }
48
+
49
+ function constructRequest(api: ApiRequest) {
50
+ // Get baseUrl and validate it. (It wont be in web component context)
51
+ let baseUrl: string | undefined = window.origin
52
+ try {
53
+ new URL(baseUrl)
54
+ } catch {
55
+ baseUrl = undefined
56
+ }
57
+
58
+ return createApiRequest({
59
+ api,
60
+ formulaContext: getFormulaContext(api),
61
+ baseUrl,
62
+ defaultHeaders: undefined,
63
+ })
64
+ }
65
+
66
+ // Create the formula context for the api
67
+ function getFormulaContext(api: ApiRequest): FormulaContext {
68
+ // Use the general formula context to evaluate the arguments of the api
69
+ const formulaContext = {
70
+ data: ctx.dataSignal.get(),
71
+ component: ctx.component,
72
+ formulaCache: ctx.formulaCache,
73
+ root: ctx.root,
74
+ package: ctx.package,
75
+ toddle: ctx.toddle,
76
+ env: ctx.env,
77
+ }
78
+
79
+ // Make sure inputs are also available in the formula context
80
+ const evaluatedInputs = Object.entries(api.inputs).reduce<
81
+ Record<string, unknown>
82
+ >((acc, [key, value]) => {
83
+ acc[key] = applyFormula(value.formula, formulaContext)
84
+ return acc
85
+ }, {})
86
+
87
+ const data = {
88
+ ...formulaContext.data,
89
+ ApiInputs: {
90
+ ...evaluatedInputs,
91
+ },
92
+ }
93
+
94
+ return {
95
+ component: ctx.component,
96
+ formulaCache: ctx.formulaCache,
97
+ root: ctx.root,
98
+ package: ctx.package,
99
+ data,
100
+ toddle: ctx.toddle,
101
+ env: ctx.env,
102
+ }
103
+ }
104
+
105
+ function handleRedirectRules(api: ApiRequest) {
106
+ for (const [ruleName, rule] of sortObjectEntries(
107
+ api.redirectRules ?? {},
108
+ ([_, rule]) => rule.index,
109
+ )) {
110
+ const location = applyFormula(rule.formula, {
111
+ ...getFormulaContext(api),
112
+ data: {
113
+ ...getFormulaContext(api).data,
114
+ Apis: {
115
+ [api.name]: ctx.dataSignal.get().Apis?.[api.name] as ApiStatus,
116
+ },
117
+ },
118
+ })
119
+ if (typeof location === 'string') {
120
+ const url = validateUrl(location, window.location.href)
121
+ if (url) {
122
+ if (ctx.env.runtime === 'preview') {
123
+ // Attempt to notify the parent about the failed navigation attempt
124
+ window.parent?.postMessage(
125
+ { type: 'blockedNavigation', url: url.href },
126
+ '*',
127
+ )
128
+ return { name: ruleName, index: rule.index, url }
129
+ } else {
130
+ window.location.replace(url.href)
131
+ }
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ function triggerActions(
138
+ eventName: 'message' | 'success' | 'failed',
139
+ api: ApiRequest,
140
+ data: {
141
+ body: unknown
142
+ status?: number
143
+ headers?: Record<string, string>
144
+ },
145
+ ) {
146
+ switch (eventName) {
147
+ case 'message': {
148
+ const event = createApiEvent('message', data.body)
149
+ api.client?.onMessage?.actions?.forEach((action) => {
150
+ handleAction(
151
+ action,
152
+ {
153
+ ...getFormulaContext(api).data,
154
+ ...ctx.dataSignal.get(),
155
+ Event: event,
156
+ },
157
+ ctx,
158
+ event,
159
+ )
160
+ })
161
+ break
162
+ }
163
+ case 'success': {
164
+ const event = createApiEvent('success', data.body)
165
+ api.client?.onCompleted?.actions?.forEach((action) => {
166
+ handleAction(
167
+ action,
168
+ {
169
+ ...getFormulaContext(api).data,
170
+ ...ctx.dataSignal.get(),
171
+ Event: event,
172
+ },
173
+ ctx,
174
+ event,
175
+ )
176
+ })
177
+ break
178
+ }
179
+ case 'failed': {
180
+ const event = createApiEvent('failed', {
181
+ error: data.body,
182
+ status: data.status,
183
+ })
184
+ api.client?.onFailed?.actions?.forEach((action) => {
185
+ handleAction(
186
+ action,
187
+ {
188
+ ...getFormulaContext(api).data,
189
+ ...ctx.dataSignal.get(),
190
+ Event: event,
191
+ },
192
+ ctx,
193
+ event,
194
+ )
195
+ })
196
+ break
197
+ }
198
+ }
199
+ }
200
+
201
+ function apiSuccess(
202
+ api: ApiRequest,
203
+ data: {
204
+ body: unknown
205
+ status?: number
206
+ headers?: Record<string, string>
207
+ },
208
+ performance: ApiPerformance,
209
+ ) {
210
+ const latestRequestStart =
211
+ ctx.dataSignal.get().Apis?.[api.name]?.response?.performance?.requestStart
212
+ if (
213
+ typeof latestRequestStart === 'number' &&
214
+ latestRequestStart > (performance.requestStart ?? 0)
215
+ ) {
216
+ return
217
+ }
218
+
219
+ ctx.dataSignal.set({
220
+ ...ctx.dataSignal.get(),
221
+ Apis: {
222
+ ...ctx.dataSignal.get().Apis,
223
+ [api.name]: {
224
+ isLoading: false,
225
+ data: data.body,
226
+ error: null,
227
+ response: {
228
+ status: data.status,
229
+ headers: data.headers,
230
+ performance,
231
+ },
232
+ },
233
+ },
234
+ })
235
+ const appliedRedirectRule = handleRedirectRules(api)
236
+ if (appliedRedirectRule) {
237
+ ctx.dataSignal.set({
238
+ ...ctx.dataSignal.get(),
239
+ Apis: {
240
+ ...ctx.dataSignal.get().Apis,
241
+ [api.name]: {
242
+ isLoading: false,
243
+ data: data.body,
244
+ error: null,
245
+ response: {
246
+ status: data.status,
247
+ headers: data.headers,
248
+ performance,
249
+ ...(ctx.env.runtime === 'preview'
250
+ ? { debug: { appliedRedirectRule } }
251
+ : {}),
252
+ },
253
+ },
254
+ },
255
+ })
256
+ }
257
+ }
258
+
259
+ function apiError(
260
+ api: ApiRequest,
261
+ data: {
262
+ body: unknown
263
+ status?: number
264
+ headers?: Record<string, string>
265
+ },
266
+ performance: ApiPerformance,
267
+ ) {
268
+ const latestRequestStart =
269
+ ctx.dataSignal.get().Apis?.[api.name]?.response?.performance?.requestStart
270
+ if (
271
+ typeof latestRequestStart === 'number' &&
272
+ latestRequestStart > (performance.requestStart ?? 0)
273
+ ) {
274
+ return
275
+ }
276
+ ctx.dataSignal.set({
277
+ ...ctx.dataSignal.get(),
278
+ Apis: {
279
+ ...ctx.dataSignal.get().Apis,
280
+ [api.name]: {
281
+ isLoading: false,
282
+ data: null,
283
+ error: data.body,
284
+ response: {
285
+ status: data.status,
286
+ headers: data.headers,
287
+ performance,
288
+ },
289
+ },
290
+ },
291
+ })
292
+ const appliedRedirectRule = handleRedirectRules(api)
293
+ if (appliedRedirectRule) {
294
+ ctx.dataSignal.set({
295
+ ...ctx.dataSignal.get(),
296
+ Apis: {
297
+ ...ctx.dataSignal.get().Apis,
298
+ [api.name]: {
299
+ isLoading: false,
300
+ data: null,
301
+ error: data.body,
302
+ response: {
303
+ status: data.status,
304
+ headers: data.headers,
305
+ performance,
306
+ ...(ctx.env.runtime === 'preview'
307
+ ? { debug: { appliedRedirectRule } }
308
+ : {}),
309
+ },
310
+ },
311
+ },
312
+ })
313
+ }
314
+ }
315
+
316
+ // Execute the request - potentially to the cloudflare Query proxy
317
+ async function execute(
318
+ api: ApiRequest,
319
+ url: URL,
320
+ requestSettings: ToddleRequestInit,
321
+ ) {
322
+ const run = async () => {
323
+ const performance: ApiPerformance = {
324
+ requestStart: Date.now(),
325
+ responseStart: null,
326
+ responseEnd: null,
327
+ }
328
+ ctx.dataSignal.set({
329
+ ...ctx.dataSignal.get(),
330
+ Apis: {
331
+ ...ctx.dataSignal.get().Apis,
332
+ [api.name]: {
333
+ isLoading: true,
334
+ data: ctx.dataSignal.get().Apis?.[api.name]?.data ?? null,
335
+ error: null,
336
+ },
337
+ },
338
+ })
339
+ let response
340
+
341
+ try {
342
+ const proxy = api.server?.proxy
343
+ ? (applyFormula(
344
+ api.server.proxy.enabled.formula,
345
+ getFormulaContext(api),
346
+ ) ?? false)
347
+ : false
348
+
349
+ if (proxy === false) {
350
+ response = await fetch(url, requestSettings)
351
+ } else {
352
+ const proxyUrl = `/.toddle/omvej/components/${encodeURIComponent(
353
+ ctx.component.name,
354
+ )}/apis/${encodeURIComponent(
355
+ ctx.component.name,
356
+ )}:${encodeURIComponent(api.name)}`
357
+ const headers = new Headers(requestSettings.headers)
358
+ headers.set(
359
+ PROXY_URL_HEADER,
360
+ decodeURIComponent(url.href.replace(/\+/g, ' ')),
361
+ )
362
+ requestSettings.headers = headers
363
+ response = await fetch(proxyUrl, requestSettings)
364
+ }
365
+
366
+ performance.responseStart = Date.now()
367
+ await handleResponse(api, response, performance)
368
+ return
369
+ } catch (error: any) {
370
+ const body = error.cause
371
+ ? { message: error.message, data: error.cause }
372
+ : error.message
373
+ apiError(api, { body }, { ...performance, responseEnd: Date.now() })
374
+ triggerActions('failed', api, { body })
375
+ return Promise.reject(error)
376
+ }
377
+ }
378
+
379
+ // Debounce the request if needed
380
+ if (api.client?.debounce?.formula) {
381
+ return new Promise((resolve, reject) => {
382
+ if (typeof timer === 'number') {
383
+ clearTimeout(timer)
384
+ }
385
+ timer = setTimeout(
386
+ () => {
387
+ run().then(resolve, reject)
388
+ },
389
+ applyFormula(api.client?.debounce?.formula, getFormulaContext(api)),
390
+ )
391
+ })
392
+ }
393
+
394
+ return run()
395
+ }
396
+
397
+ function handleResponse(
398
+ api: ApiRequest,
399
+ res: Response,
400
+ performance: ApiPerformance,
401
+ ) {
402
+ let parserMode = api.client?.parserMode ?? 'auto'
403
+
404
+ if (parserMode === 'auto') {
405
+ const contentType = res.headers.get('content-type')
406
+ if (isEventStreamHeader(contentType)) {
407
+ parserMode = 'event-stream'
408
+ } else if (isJsonHeader(contentType)) {
409
+ parserMode = 'json'
410
+ } else if (isTextHeader(contentType)) {
411
+ parserMode = 'text'
412
+ } else if (isJsonStreamHeader(contentType)) {
413
+ parserMode = 'json-stream'
414
+ } else if (isImageHeader(contentType)) {
415
+ parserMode = 'blob'
416
+ } else {
417
+ parserMode = 'text'
418
+ }
419
+ }
420
+
421
+ switch (parserMode) {
422
+ case 'text':
423
+ return textStreamResponse(api, res, performance)
424
+ case 'json':
425
+ return jsonResponse(api, res, performance)
426
+ case 'event-stream':
427
+ return eventStreamingResponse(api, res, performance)
428
+ case 'json-stream':
429
+ return jsonStreamResponse(api, res, performance)
430
+ case 'blob':
431
+ return blobResponse(api, res, performance)
432
+ default:
433
+ return textStreamResponse(api, res, performance)
434
+ }
435
+ }
436
+
437
+ function textStreamResponse(
438
+ api: ApiRequest,
439
+ res: Response,
440
+ performance: ApiPerformance,
441
+ ) {
442
+ return handleStreaming({
443
+ api,
444
+ res,
445
+ performance,
446
+ streamType: 'text',
447
+ useTextDecoder: true,
448
+ parseChunk: (chunk) => chunk,
449
+ parseChunksForData: (chunks) => chunks.join(''),
450
+ })
451
+ }
452
+
453
+ function jsonStreamResponse(
454
+ api: ApiRequest,
455
+ res: Response,
456
+ performance: ApiPerformance,
457
+ ) {
458
+ const parseChunk = (chunk: any) => {
459
+ let parsedData = chunk
460
+ try {
461
+ parsedData = JSON.parse(chunk)
462
+ } catch {
463
+ throw new Error('Error occurred while parsing the json chunk.', {
464
+ cause: parsedData,
465
+ })
466
+ }
467
+ return parsedData
468
+ }
469
+
470
+ return handleStreaming({
471
+ api,
472
+ res,
473
+ performance,
474
+ streamType: 'json',
475
+ useTextDecoder: true,
476
+ parseChunk,
477
+ parseChunksForData: (chunks) => [...chunks],
478
+ delimiters: ['\r\n', '\n'],
479
+ })
480
+ }
481
+
482
+ async function jsonResponse(
483
+ api: ApiRequest,
484
+ res: Response,
485
+ performance: ApiPerformance,
486
+ ) {
487
+ const body = await res.json()
488
+
489
+ const status: ApiStatus = {
490
+ data: body,
491
+ isLoading: false,
492
+ error: null,
493
+ response: {
494
+ status: res.status,
495
+ headers: Object.fromEntries(res.headers.entries()),
496
+ },
497
+ }
498
+ return endResponse(api, status, performance)
499
+ }
500
+
501
+ async function blobResponse(
502
+ api: ApiRequest,
503
+ res: Response,
504
+ performance: ApiPerformance,
505
+ ) {
506
+ const blob = await res.blob()
507
+
508
+ const status: ApiStatus = {
509
+ isLoading: false,
510
+ data: URL.createObjectURL(blob),
511
+ error: null,
512
+ response: {
513
+ status: res.status,
514
+ headers: Object.fromEntries(res.headers.entries()),
515
+ },
516
+ }
517
+ return endResponse(api, status, performance)
518
+ }
519
+
520
+ function eventStreamingResponse(
521
+ api: ApiRequest,
522
+ res: Response,
523
+ performance: ApiPerformance,
524
+ ) {
525
+ const parseChunk = (chunk: string) => {
526
+ const event = chunk.match(/event: (.*)/)?.[1] ?? 'message'
527
+ const data = chunk.match(/data: (.*)/)?.[1] ?? ''
528
+ const id = chunk.match(/id: (.*)/)?.[1]
529
+ const retry = chunk.match(/retry: (.*)/)?.[1]
530
+
531
+ let parsedData = data
532
+ try {
533
+ parsedData = JSON.parse(data ?? '')
534
+ // eslint-disable-next-line no-empty
535
+ } catch {}
536
+ const returnData = {
537
+ event,
538
+ data: parsedData,
539
+ ...(id ? { id } : {}),
540
+ ...(retry ? { retry } : {}),
541
+ }
542
+ return returnData
543
+ }
544
+ return handleStreaming({
545
+ api,
546
+ res,
547
+ performance,
548
+ streamType: 'event',
549
+ useTextDecoder: true,
550
+ parseChunk,
551
+ parseChunksForData: (chunks) => [...chunks],
552
+ delimiters: ['\n\n', '\r\n\r\n'],
553
+ })
554
+ }
555
+
556
+ async function handleStreaming({
557
+ api,
558
+ res,
559
+ performance,
560
+ streamType,
561
+ useTextDecoder,
562
+ parseChunk,
563
+ parseChunksForData,
564
+ delimiters, // There can be various delimiters for the same stream. SSE might use both \n\n and \r\n\r\n
565
+ }: {
566
+ api: ApiRequest
567
+ res: Response
568
+ performance: ApiPerformance
569
+ streamType: 'json' | 'text' | 'event'
570
+ useTextDecoder: boolean
571
+ parseChunk: (chunk: any) => any
572
+ parseChunksForData: (chunks: any[]) => any
573
+ delimiters?: string[]
574
+ }) {
575
+ const chunks: {
576
+ chunks: any[]
577
+ currentChunk: string
578
+ add(chunk: string | Uint8Array): void
579
+ processChunk(chunk: string | Uint8Array): void
580
+ } = {
581
+ chunks: [],
582
+ currentChunk: '',
583
+ // Function to add a chunk to the chunks array and emits the data to the onMessage event
584
+ add(chunk: string | Uint8Array) {
585
+ const parsedChunk = parseChunk(chunk)
586
+ this.chunks.push(parsedChunk)
587
+ // Only emit the data if there are any listeners
588
+ if (parsedChunk) {
589
+ ctx.dataSignal.set({
590
+ ...ctx.dataSignal.get(),
591
+ Apis: {
592
+ ...ctx.dataSignal.get().Apis,
593
+ [api.name]: {
594
+ isLoading: true,
595
+ data: parseChunksForData(this.chunks),
596
+ error: null,
597
+ response: {
598
+ headers: Object.fromEntries(res.headers.entries()),
599
+ },
600
+ },
601
+ },
602
+ })
603
+ if ((api.client?.onMessage?.actions ?? []).length > 0) {
604
+ triggerActions('message', api, { body: parsedChunk })
605
+ }
606
+ }
607
+ },
608
+
609
+ // Function to process a chunk and split it by the delimiter.
610
+ processChunk(chunk: any) {
611
+ const delimiter = delimiters?.find((d) => chunk.includes(d))
612
+ const concatenated = this.currentChunk + chunk
613
+ const split = delimiter ? concatenated.split(delimiter) : [concatenated]
614
+ this.currentChunk = split.pop() ?? ''
615
+ split.forEach((c) => this.add(c))
616
+ },
617
+ }
618
+
619
+ const reader = useTextDecoder
620
+ ? res.body?.pipeThrough(new TextDecoderStream()).getReader()
621
+ : res.body?.getReader()
622
+
623
+ while (reader) {
624
+ const { done, value } = await reader.read()
625
+ if (done) {
626
+ break
627
+ }
628
+
629
+ if (delimiters) {
630
+ chunks.processChunk(value)
631
+ } else {
632
+ chunks.add(value)
633
+ }
634
+ }
635
+
636
+ // First make sure theres no remaining chunk
637
+ if (chunks.currentChunk) {
638
+ chunks.add(chunks.currentChunk)
639
+ }
640
+
641
+ const status: ApiStatus = {
642
+ isLoading: false,
643
+ data: chunks.chunks,
644
+ error: null,
645
+ response: {
646
+ status: res.status,
647
+ headers: Object.fromEntries(res.headers.entries()),
648
+ },
649
+ }
650
+
651
+ try {
652
+ if (streamType === 'json') {
653
+ const parsed = JSON.parse(chunks.chunks.join(''))
654
+ status.data = parsed
655
+ } else if (streamType === 'text') {
656
+ status.data = chunks.chunks.join('')
657
+ }
658
+ } catch {
659
+ throw new Error('Error occurred while parsing the json chunk.', {
660
+ cause: chunks.chunks.join(''),
661
+ })
662
+ }
663
+ return endResponse(api, status, performance)
664
+ }
665
+
666
+ function endResponse(
667
+ api: ApiRequest,
668
+ apiStatus: ApiStatus,
669
+ performance: ApiPerformance,
670
+ ) {
671
+ performance.responseEnd = Date.now()
672
+
673
+ const data = {
674
+ body: apiStatus.data,
675
+ status: apiStatus.response?.status,
676
+ headers: apiStatus.response?.headers ?? undefined,
677
+ }
678
+
679
+ const isError = isApiError({
680
+ apiName: api.name,
681
+ response: {
682
+ body: data.body,
683
+ ok: Boolean(
684
+ !apiStatus.error &&
685
+ apiStatus.response?.status &&
686
+ apiStatus.response.status < 400,
687
+ ),
688
+ status: data.status,
689
+ headers: data.headers,
690
+ },
691
+ formulaContext: getFormulaContext(api),
692
+ errorFormula: api.isError,
693
+ performance,
694
+ })
695
+
696
+ if (isError) {
697
+ if (!data.body && apiStatus.error) {
698
+ data.body = apiStatus.error
699
+ }
700
+
701
+ apiError(api, data, performance)
702
+ triggerActions('failed', api, data)
703
+ } else {
704
+ apiSuccess(api, data, performance)
705
+ triggerActions('success', api, data)
706
+ }
707
+ }
708
+
709
+ function getApiForComparison(api: ApiRequest) {
710
+ return omitPaths(api, [
711
+ ['client', 'onCompleted'],
712
+ ['client', 'onFailed'],
713
+ ['client', 'onMessage'],
714
+ ['service'],
715
+ ['server', 'ssr'],
716
+ ]) as NestedOmit<
717
+ ApiRequest,
718
+ | 'client.onCompleted'
719
+ | 'client.onFailed'
720
+ | 'client.onMessage'
721
+ | 'service'
722
+ | 'server.ssr'
723
+ >
724
+ }
725
+
726
+ let payloadSignal:
727
+ | Signal<{
728
+ request: ReturnType<typeof constructRequest>
729
+ api: ReturnType<typeof getApiForComparison>
730
+ // if the evaluated value of autoFetch changes from false -> true, we need to refetch the api
731
+ autoFetch: boolean
732
+ // currently, the proxy setting is always controlled by a "value formula", but in case we later
733
+ // open up for controlling it with a dynamic formula, we should also include it here
734
+ proxy: boolean
735
+ }>
736
+ | undefined
737
+
738
+ // eslint-disable-next-line prefer-const
739
+ payloadSignal = ctx.dataSignal.map((_) => {
740
+ const payloadContext = getFormulaContext(api)
741
+ return {
742
+ request: constructRequest(api),
743
+ api: getApiForComparison(api),
744
+ autoFetch: api.autoFetch
745
+ ? applyFormula(api.autoFetch, payloadContext)
746
+ : false,
747
+ proxy: applyFormula(api.server?.proxy?.enabled.formula, payloadContext),
748
+ }
749
+ })
750
+ payloadSignal.subscribe(async (_) => {
751
+ if (api.autoFetch && applyFormula(api.autoFetch, getFormulaContext(api))) {
752
+ // Ensure we only use caching if the page is currently loading
753
+ if ((window?.__toddle?.isPageLoaded ?? false) === false) {
754
+ const { url, requestSettings } = constructRequest(api)
755
+ const cacheKey = requestHash(url, requestSettings)
756
+ const cacheMatch = ctx.toddle.pageState.Apis?.[cacheKey] as ApiStatus
757
+ if (cacheMatch) {
758
+ if (cacheMatch.error) {
759
+ apiError(
760
+ api,
761
+ {
762
+ body: cacheMatch.error,
763
+ status: cacheMatch.response?.status,
764
+ headers: cacheMatch.response?.headers ?? undefined,
765
+ },
766
+ {
767
+ requestStart:
768
+ cacheMatch.response?.performance?.requestStart ?? null,
769
+ responseStart:
770
+ cacheMatch.response?.performance?.responseStart ?? null,
771
+ responseEnd:
772
+ cacheMatch.response?.performance?.responseEnd ?? null,
773
+ },
774
+ )
775
+ } else {
776
+ apiSuccess(
777
+ api,
778
+ {
779
+ body: cacheMatch.data,
780
+ status: cacheMatch.response?.status,
781
+ headers: cacheMatch.response?.headers ?? undefined,
782
+ },
783
+ {
784
+ requestStart:
785
+ cacheMatch.response?.performance?.requestStart ?? null,
786
+ responseStart:
787
+ cacheMatch.response?.performance?.responseStart ?? null,
788
+ responseEnd:
789
+ cacheMatch.response?.performance?.responseEnd ?? null,
790
+ },
791
+ )
792
+ }
793
+ } else {
794
+ // Execute will set the initial status of the api in the dataSignal
795
+ await execute(api, url, requestSettings)
796
+ }
797
+ } else {
798
+ // Execute will set the initial status of the api in the dataSignal
799
+ const { url, requestSettings } = constructRequest(api)
800
+ await execute(api, url, requestSettings)
801
+ }
802
+ } else {
803
+ ctx.dataSignal.update((data) => {
804
+ return {
805
+ ...data,
806
+ Apis: {
807
+ ...(data.Apis ?? {}),
808
+ [api.name]: {
809
+ isLoading: false,
810
+ data: null,
811
+ error: null,
812
+ },
813
+ },
814
+ }
815
+ })
816
+ }
817
+ })
818
+
819
+ return {
820
+ fetch: ({
821
+ actionInputs,
822
+ actionModels,
823
+ }: {
824
+ actionInputs?: Record<
825
+ string,
826
+ | ValueOperationValue
827
+ | {
828
+ name: string
829
+ formula?: Formula
830
+ }
831
+ >
832
+ actionModels?: {
833
+ onCompleted: ActionModel[]
834
+ onFailed: ActionModel[]
835
+ onMessage: ActionModel[]
836
+ }
837
+ }) => {
838
+ // Inputs might already be evaluated. If they are we add them as a value formula to be evaluated later.
839
+ const inputs = Object.entries(actionInputs ?? {}).reduce<
840
+ Record<
841
+ string,
842
+ {
843
+ formula: Formula
844
+ }
845
+ >
846
+ >((acc, [inputName, input]) => {
847
+ if (input !== null && typeof input === 'object' && 'formula' in input) {
848
+ acc[inputName] = input as {
849
+ formula: Formula
850
+ }
851
+ } else {
852
+ acc[inputName] = {
853
+ formula: { type: 'value', value: input },
854
+ }
855
+ }
856
+ return acc
857
+ }, {})
858
+
859
+ const apiWithInputsAndActions: ApiRequest = {
860
+ ...api,
861
+ inputs: { ...api.inputs, ...inputs },
862
+ client: {
863
+ ...api.client,
864
+ parserMode: api.client?.parserMode ?? 'auto',
865
+ onCompleted: {
866
+ trigger: api.client?.onCompleted?.trigger ?? 'success',
867
+ actions: [
868
+ ...(api.client?.onCompleted?.actions ?? []),
869
+ ...(actionModels?.onCompleted ?? []),
870
+ ],
871
+ },
872
+ onFailed: {
873
+ trigger: api.client?.onFailed?.trigger ?? 'failed',
874
+ actions: [
875
+ ...(api.client?.onFailed?.actions ?? []),
876
+ ...(actionModels?.onFailed ?? []),
877
+ ],
878
+ },
879
+ onMessage: {
880
+ trigger: api.client?.onMessage?.trigger ?? 'message',
881
+ actions: [
882
+ ...(api.client?.onMessage?.actions ?? []),
883
+ ...(actionModels?.onMessage ?? []),
884
+ ],
885
+ },
886
+ },
887
+ }
888
+
889
+ const { url, requestSettings } = constructRequest(apiWithInputsAndActions)
890
+
891
+ return execute(apiWithInputsAndActions, url, requestSettings)
892
+ },
893
+ update: (newApi: ApiRequest) => {
894
+ api = newApi
895
+ const updateContext = getFormulaContext(api)
896
+ const autoFetch =
897
+ api.autoFetch && applyFormula(api.autoFetch, updateContext)
898
+ if (autoFetch) {
899
+ payloadSignal?.set({
900
+ request: constructRequest(newApi),
901
+ api: getApiForComparison(newApi),
902
+ autoFetch,
903
+ proxy: applyFormula(
904
+ newApi.server?.proxy?.enabled.formula,
905
+ updateContext,
906
+ ),
907
+ })
908
+ }
909
+ },
910
+ triggerActions: () => {
911
+ const apiData = ctx.dataSignal.get().Apis?.[api.name]
912
+ if (
913
+ apiData === undefined ||
914
+ (apiData.data === null && apiData.error === null)
915
+ ) {
916
+ return
917
+ }
918
+ if (apiData.error) {
919
+ triggerActions('failed', api, {
920
+ body: apiData.error,
921
+ status: apiData.response?.status,
922
+ })
923
+ } else {
924
+ triggerActions('success', api, {
925
+ body: apiData.data,
926
+ })
927
+ }
928
+ },
929
+ destroy: () => payloadSignal?.destroy(),
930
+ }
931
+ }