@pikku/core 0.12.20 → 0.12.21

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 (138) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/dev/hot-reload.js +20 -6
  3. package/dist/errors/error-handler.d.ts +1 -1
  4. package/dist/errors/error-handler.js +2 -2
  5. package/dist/function/functions.types.d.ts +3 -2
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +2 -2
  8. package/dist/middleware/index.d.ts +2 -2
  9. package/dist/middleware/index.js +2 -2
  10. package/dist/middleware/telemetry.d.ts +2 -2
  11. package/dist/middleware/telemetry.js +2 -2
  12. package/dist/middleware-runner.d.ts +15 -27
  13. package/dist/middleware-runner.js +25 -30
  14. package/dist/permissions.d.ts +15 -22
  15. package/dist/permissions.js +30 -25
  16. package/dist/pikku-request.js +1 -1
  17. package/dist/pikku-state.js +2 -3
  18. package/dist/services/ai-agent-runner-service.d.ts +1 -0
  19. package/dist/services/in-memory-queue-service.js +1 -1
  20. package/dist/services/in-memory-workflow-service.d.ts +15 -13
  21. package/dist/services/in-memory-workflow-service.js +31 -13
  22. package/dist/services/local-content.js +8 -1
  23. package/dist/services/user-session-service.d.ts +1 -1
  24. package/dist/testing/service-tests.js +24 -0
  25. package/dist/types/core.types.d.ts +4 -0
  26. package/dist/types/state.types.d.ts +2 -18
  27. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +24 -5
  28. package/dist/wirings/ai-agent/ai-agent-memory.js +128 -23
  29. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +10 -1
  30. package/dist/wirings/ai-agent/ai-agent-model-config.js +15 -35
  31. package/dist/wirings/ai-agent/ai-agent-prepare.js +4 -1
  32. package/dist/wirings/ai-agent/ai-agent-runner.js +45 -31
  33. package/dist/wirings/ai-agent/ai-agent-stream.js +63 -34
  34. package/dist/wirings/ai-agent/ai-agent.types.d.ts +20 -0
  35. package/dist/wirings/channel/channel-handler.js +1 -1
  36. package/dist/wirings/channel/channel-store.d.ts +13 -0
  37. package/dist/wirings/channel/channel.types.d.ts +3 -0
  38. package/dist/wirings/channel/local/local-channel-runner.js +23 -5
  39. package/dist/wirings/channel/pikku-abstract-channel-handler.js +8 -0
  40. package/dist/wirings/channel/serverless/serverless-channel-runner.js +9 -0
  41. package/dist/wirings/cli/cli-runner.js +24 -5
  42. package/dist/wirings/cli/command-parser.js +19 -4
  43. package/dist/wirings/http/http-runner.js +72 -36
  44. package/dist/wirings/http/http.types.d.ts +1 -0
  45. package/dist/wirings/http/pikku-fetch-http-request.js +3 -1
  46. package/dist/wirings/http/web-request.js +32 -0
  47. package/dist/wirings/rpc/rpc-runner.js +13 -3
  48. package/dist/wirings/workflow/graph/graph-node.d.ts +8 -0
  49. package/dist/wirings/workflow/graph/graph-node.js +4 -0
  50. package/dist/wirings/workflow/graph/graph-runner.js +12 -10
  51. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +4 -0
  52. package/dist/wirings/workflow/index.d.ts +1 -1
  53. package/dist/wirings/workflow/pikku-workflow-service.d.ts +81 -16
  54. package/dist/wirings/workflow/pikku-workflow-service.js +266 -45
  55. package/dist/wirings/workflow/workflow.types.d.ts +34 -0
  56. package/package.json +1 -1
  57. package/run-tests.sh +4 -1
  58. package/src/dev/hot-reload.test.ts +62 -11
  59. package/src/dev/hot-reload.ts +28 -7
  60. package/src/errors/error-handler.ts +8 -2
  61. package/src/errors/error.test.ts +2 -0
  62. package/src/function/function-runner.test.ts +500 -10
  63. package/src/function/functions.types.ts +3 -2
  64. package/src/index.ts +12 -2
  65. package/src/middleware/index.ts +11 -2
  66. package/src/middleware/telemetry.ts +2 -2
  67. package/src/middleware-runner.test.ts +16 -16
  68. package/src/middleware-runner.ts +42 -30
  69. package/src/permissions.test.ts +27 -24
  70. package/src/permissions.ts +41 -25
  71. package/src/pikku-request.test.ts +35 -0
  72. package/src/pikku-request.ts +1 -1
  73. package/src/pikku-state.ts +2 -3
  74. package/src/run-tests-script.test.ts +18 -0
  75. package/src/services/ai-agent-runner-service.ts +1 -0
  76. package/src/services/in-memory-queue-service.ts +1 -1
  77. package/src/services/in-memory-session-store.ts +1 -2
  78. package/src/services/in-memory-workflow-service.test.ts +33 -11
  79. package/src/services/in-memory-workflow-service.ts +49 -13
  80. package/src/services/local-content.test.ts +54 -0
  81. package/src/services/local-content.ts +12 -1
  82. package/src/services/typed-credential-service.ts +3 -3
  83. package/src/services/typed-secret-service.ts +3 -3
  84. package/src/services/typed-variables-service.ts +3 -3
  85. package/src/services/user-session-service.ts +4 -4
  86. package/src/testing/service-tests.ts +30 -0
  87. package/src/types/core.types.ts +6 -2
  88. package/src/types/state.types.ts +2 -13
  89. package/src/wirings/ai-agent/ai-agent-memory.test.ts +324 -0
  90. package/src/wirings/ai-agent/ai-agent-memory.ts +187 -36
  91. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +12 -90
  92. package/src/wirings/ai-agent/ai-agent-model-config.ts +14 -38
  93. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +292 -0
  94. package/src/wirings/ai-agent/ai-agent-prepare.ts +4 -1
  95. package/src/wirings/ai-agent/ai-agent-registry.test.ts +230 -3
  96. package/src/wirings/ai-agent/ai-agent-runner.test.ts +625 -6
  97. package/src/wirings/ai-agent/ai-agent-runner.ts +65 -50
  98. package/src/wirings/ai-agent/ai-agent-stream.test.ts +544 -5
  99. package/src/wirings/ai-agent/ai-agent-stream.ts +71 -69
  100. package/src/wirings/ai-agent/ai-agent.types.ts +24 -0
  101. package/src/wirings/channel/channel-handler.test.ts +272 -0
  102. package/src/wirings/channel/channel-handler.ts +1 -1
  103. package/src/wirings/channel/channel-middleware-runner.test.ts +163 -0
  104. package/src/wirings/channel/channel-store.ts +19 -0
  105. package/src/wirings/channel/channel.types.ts +4 -0
  106. package/src/wirings/channel/local/local-channel-runner.test.ts +63 -0
  107. package/src/wirings/channel/local/local-channel-runner.ts +41 -5
  108. package/src/wirings/channel/local/local-eventhub-service.ts +3 -3
  109. package/src/wirings/channel/pikku-abstract-channel-handler.ts +9 -2
  110. package/src/wirings/channel/serverless/serverless-channel-runner.ts +23 -5
  111. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +7 -2
  112. package/src/wirings/cli/cli-runner.test.ts +255 -2
  113. package/src/wirings/cli/cli-runner.ts +31 -10
  114. package/src/wirings/cli/cli.types.ts +4 -8
  115. package/src/wirings/cli/command-parser.test.ts +83 -0
  116. package/src/wirings/cli/command-parser.ts +23 -4
  117. package/src/wirings/http/http-runner.test.ts +296 -1
  118. package/src/wirings/http/http-runner.ts +87 -57
  119. package/src/wirings/http/http.types.ts +11 -26
  120. package/src/wirings/http/pikku-fetch-http-request.ts +8 -4
  121. package/src/wirings/http/pikku-fetch-http-response.test.ts +41 -0
  122. package/src/wirings/http/web-request.test.ts +115 -0
  123. package/src/wirings/http/web-request.ts +43 -5
  124. package/src/wirings/mcp/mcp-runner.test.ts +367 -0
  125. package/src/wirings/queue/queue.types.ts +2 -5
  126. package/src/wirings/rpc/rpc-runner.test.ts +511 -0
  127. package/src/wirings/rpc/rpc-runner.ts +15 -3
  128. package/src/wirings/rpc/wire-addon.test.ts +57 -0
  129. package/src/wirings/workflow/graph/graph-node.ts +12 -0
  130. package/src/wirings/workflow/graph/graph-runner.test.ts +98 -0
  131. package/src/wirings/workflow/graph/graph-runner.ts +28 -10
  132. package/src/wirings/workflow/graph/workflow-graph.types.ts +4 -0
  133. package/src/wirings/workflow/index.ts +1 -0
  134. package/src/wirings/workflow/pikku-workflow-service.test.ts +19 -2
  135. package/src/wirings/workflow/pikku-workflow-service.ts +370 -71
  136. package/src/wirings/workflow/workflow-queue-workers.test.ts +131 -0
  137. package/src/wirings/workflow/workflow.types.ts +68 -5
  138. package/tsconfig.tsbuildinfo +1 -1
@@ -2,7 +2,15 @@ import { test, describe, beforeEach, afterEach } from 'node:test'
2
2
  import * as assert from 'assert'
3
3
  import { NotFoundError } from '../../errors/errors.js'
4
4
  import type { JSONValue, CorePikkuMiddleware } from '../../types/core.types.js'
5
- import { fetch, wireHTTP } from './http-runner.js'
5
+ import {
6
+ addHTTPMiddleware,
7
+ addHTTPPermission,
8
+ createHTTPWire,
9
+ fetch,
10
+ fetchData,
11
+ pikkuFetch,
12
+ wireHTTP,
13
+ } from './http-runner.js'
6
14
  import { pikkuState, resetPikkuState } from '../../pikku-state.js'
7
15
  import {
8
16
  PikkuMockRequest,
@@ -16,6 +24,62 @@ const sessionMiddleware: CorePikkuMiddleware = async (services, wire, next) => {
16
24
  await next()
17
25
  }
18
26
 
27
+ class TestRequest extends PikkuMockRequest {
28
+ private _headers: Record<string, string> = {}
29
+ private _data: unknown = {}
30
+
31
+ setHeaders(headers: Record<string, string>) {
32
+ this._headers = headers
33
+ }
34
+
35
+ setData(data: unknown) {
36
+ this._data = data
37
+ }
38
+
39
+ headers(): Record<string, string> {
40
+ return this._headers
41
+ }
42
+
43
+ header(headerName: string): string | null {
44
+ return this._headers[headerName] ?? null
45
+ }
46
+
47
+ async data() {
48
+ return this._data
49
+ }
50
+ }
51
+
52
+ class TestResponse extends PikkuMockResponse {
53
+ public headersMap = new Map<string, string | string[]>()
54
+ public jsonBody: unknown
55
+ public bufferBody: unknown
56
+ public mode: 'stream' | null = null
57
+ public closed = false
58
+
59
+ header(name: string, value: string | string[]): this {
60
+ this.headersMap.set(name, value)
61
+ return this
62
+ }
63
+
64
+ arrayBuffer(data: XMLHttpRequestBodyInit): this {
65
+ this.bufferBody = data
66
+ return this
67
+ }
68
+
69
+ json(data: unknown): this {
70
+ this.jsonBody = data
71
+ return this
72
+ }
73
+
74
+ setMode(mode: 'stream') {
75
+ this.mode = mode
76
+ }
77
+
78
+ close() {
79
+ this.closed = true
80
+ }
81
+ }
82
+
19
83
  const setHTTPFunctionMap = (func: any) => {
20
84
  pikkuState(null, 'function', 'meta', {
21
85
  pikku_func_name: {
@@ -41,6 +105,35 @@ const setHTTPFunctionMap = (func: any) => {
41
105
  addFunction('pikku_func_name', { func })
42
106
  }
43
107
 
108
+ const setRouteMeta = (
109
+ route: string,
110
+ method:
111
+ | 'get'
112
+ | 'post'
113
+ | 'put'
114
+ | 'delete'
115
+ | 'patch'
116
+ | 'head'
117
+ | 'options' = 'get',
118
+ overrides: Record<string, unknown> = {}
119
+ ) => {
120
+ const meta = pikkuState(null, 'http', 'meta')
121
+ meta[method][route] = {
122
+ pikkuFuncId: 'pikku_func_name',
123
+ route,
124
+ method,
125
+ ...overrides,
126
+ } as any
127
+ pikkuState(null, 'function', 'meta')['pikku_func_name'] = {
128
+ pikkuFuncId: 'pikku_func_name',
129
+ inputSchemaName: null,
130
+ outputSchemaName: null,
131
+ sessionless: true,
132
+ permissions: undefined,
133
+ middleware: undefined,
134
+ } as any
135
+ }
136
+
44
137
  describe('fetch', () => {
45
138
  let request: any
46
139
  let response: any
@@ -142,3 +235,205 @@ describe('fetch', () => {
142
235
  )
143
236
  })
144
237
  })
238
+
239
+ describe('http-runner helpers', () => {
240
+ beforeEach(() => {
241
+ resetPikkuState()
242
+ httpRouter.reset()
243
+ pikkuState(null, 'package', 'singletonServices', {
244
+ logger: {
245
+ info: () => {},
246
+ warn: () => {},
247
+ error: () => {},
248
+ },
249
+ } as any)
250
+ pikkuState(null, 'package', 'factories', {
251
+ createWireServices: async () => ({}),
252
+ } as any)
253
+ })
254
+
255
+ test('addHTTPMiddleware and addHTTPPermission register route groups', () => {
256
+ const middleware = [sessionMiddleware]
257
+ const permissions = [async () => true]
258
+
259
+ assert.strictEqual(addHTTPMiddleware('/api/*', middleware), middleware)
260
+ assert.strictEqual(
261
+ addHTTPPermission('/api/*', permissions as any),
262
+ permissions
263
+ )
264
+ assert.strictEqual(
265
+ pikkuState(null, 'middleware', 'httpGroup')['/api/*'],
266
+ middleware
267
+ )
268
+ assert.strictEqual(
269
+ pikkuState(null, 'permissions', 'httpGroup')['/api/*'],
270
+ permissions
271
+ )
272
+ })
273
+
274
+ test('createHTTPWire combines request and response when present', () => {
275
+ const req = new TestRequest('/x', 'get')
276
+ const res = new TestResponse()
277
+
278
+ assert.deepEqual(createHTTPWire(undefined, undefined), undefined)
279
+ assert.deepEqual(createHTTPWire(req, undefined), { request: req })
280
+ assert.deepEqual(createHTTPWire(undefined, res), { response: res })
281
+ assert.deepEqual(createHTTPWire(req, res), { request: req, response: res })
282
+ })
283
+
284
+ test('wireHTTP skips routes without metadata', () => {
285
+ const warnings: string[] = []
286
+ const originalWarn = console.warn
287
+ console.warn = (message: string) => {
288
+ warnings.push(message)
289
+ }
290
+
291
+ try {
292
+ wireHTTP({
293
+ route: '/missing',
294
+ method: 'get',
295
+ func: { func: async () => ({ ok: true }) },
296
+ })
297
+ } finally {
298
+ console.warn = originalWarn
299
+ }
300
+
301
+ assert.equal(
302
+ pikkuState(null, 'http', 'routes').get('get')?.has('/missing') ?? false,
303
+ false
304
+ )
305
+ assert.match(warnings[0] || '', /Skipping HTTP route 'GET \/missing'/)
306
+ })
307
+
308
+ test('wireHTTP registers route functions when metadata exists', () => {
309
+ setRouteMeta('/registered')
310
+
311
+ wireHTTP({
312
+ route: '/registered',
313
+ method: 'get',
314
+ func: { func: async () => ({ ok: true }) },
315
+ })
316
+
317
+ httpRouter.initialize()
318
+
319
+ assert.equal(
320
+ pikkuState(null, 'http', 'routes').get('get')?.has('/registered'),
321
+ true
322
+ )
323
+ })
324
+
325
+ test('fetchData handles OPTIONS preflight through global middleware', async () => {
326
+ const request = new TestRequest('/anything', 'options')
327
+ const response = new TestResponse()
328
+ const execution: string[] = []
329
+
330
+ addHTTPMiddleware('*', [
331
+ async (_services, _wire, next) => {
332
+ execution.push('global')
333
+ await next?.()
334
+ },
335
+ ])
336
+
337
+ await fetchData(request, response)
338
+
339
+ assert.equal(response.statusCode, 204)
340
+ assert.equal(response.jsonBody, undefined)
341
+ assert.deepEqual(execution, ['global'])
342
+ })
343
+
344
+ test('fetchData sets 204 when a route returns undefined', async () => {
345
+ setRouteMeta('/no-content')
346
+ addFunction('pikku_func_name', {
347
+ func: async () => undefined,
348
+ })
349
+ wireHTTP({
350
+ route: '/no-content',
351
+ method: 'get',
352
+ auth: false,
353
+ func: { func: async () => undefined },
354
+ })
355
+ httpRouter.initialize()
356
+
357
+ const request = new TestRequest('/no-content', 'get')
358
+ const response = new TestResponse()
359
+
360
+ await fetchData(request, response)
361
+
362
+ assert.equal(response.statusCode, 204)
363
+ })
364
+
365
+ test('fetchData writes binary responses when returnsJSON is false', async () => {
366
+ setRouteMeta('/binary')
367
+ wireHTTP({
368
+ route: '/binary',
369
+ method: 'get',
370
+ auth: false,
371
+ returnsJSON: false,
372
+ func: { func: async () => 'raw-body' },
373
+ })
374
+ httpRouter.initialize()
375
+
376
+ const request = new TestRequest('/binary', 'get')
377
+ const response = new TestResponse()
378
+
379
+ await fetchData(request, response)
380
+
381
+ assert.equal(response.bufferBody, 'raw-body')
382
+ assert.equal(response.jsonBody, undefined)
383
+ })
384
+
385
+ test('fetchData applies native Response results', async () => {
386
+ setRouteMeta('/native-response')
387
+ wireHTTP({
388
+ route: '/native-response',
389
+ method: 'get',
390
+ auth: false,
391
+ func: {
392
+ func: async () =>
393
+ new Response('hello', {
394
+ status: 202,
395
+ headers: { 'content-type': 'text/plain', 'x-test': 'ok' },
396
+ }),
397
+ },
398
+ })
399
+ httpRouter.initialize()
400
+
401
+ const result = await pikkuFetch(
402
+ new Request('https://example.com/native-response')
403
+ )
404
+
405
+ const response = result.toResponse()
406
+ assert.equal(response.status, 202)
407
+ assert.equal(await response.text(), 'hello')
408
+ assert.equal(response.headers.get('x-test'), 'ok')
409
+ })
410
+
411
+ test('fetchData configures SSE mode and streams through channel.send', async () => {
412
+ setRouteMeta('/sse', 'get', { sse: true })
413
+ wireHTTP({
414
+ route: '/sse',
415
+ method: 'get',
416
+ sse: true,
417
+ auth: false,
418
+ func: {
419
+ func: async (_services, _data, wire) => {
420
+ await wire.channel.send({ hello: 'world' })
421
+ wire.channel.close()
422
+ },
423
+ },
424
+ })
425
+ httpRouter.initialize()
426
+
427
+ const request = new TestRequest('/sse', 'get')
428
+ request.setData({ open: true })
429
+ const response = new TestResponse()
430
+
431
+ await fetchData(request, response)
432
+
433
+ assert.equal(response.mode, 'stream')
434
+ assert.equal(response.headersMap.get('Content-Type'), 'text/event-stream')
435
+ assert.equal(response.headersMap.get('Cache-Control'), 'no-cache')
436
+ assert.equal(response.closed, true)
437
+ assert.equal(response.bufferBody, JSON.stringify({ hello: 'world' }))
438
+ })
439
+ })
@@ -159,12 +159,10 @@ export const wireHTTP = <
159
159
  Out,
160
160
  Route extends string,
161
161
  PikkuFunction extends CorePikkuFunction<In, Out> = CorePikkuFunction<In, Out>,
162
- PikkuFunctionSessionless extends CorePikkuFunctionSessionless<
163
- In,
164
- Out
165
- > = CorePikkuFunctionSessionless<In, Out>,
166
- PikkuPermissionGroup extends
167
- CorePikkuPermission<In> = CorePikkuPermission<In>,
162
+ PikkuFunctionSessionless extends CorePikkuFunctionSessionless<In, Out> =
163
+ CorePikkuFunctionSessionless<In, Out>,
164
+ PikkuPermissionGroup extends CorePikkuPermission<In> =
165
+ CorePikkuPermission<In>,
168
166
  PikkuMiddleware extends CorePikkuMiddleware = CorePikkuMiddleware,
169
167
  >(
170
168
  httpWiring: CoreHTTPFunctionWiring<
@@ -360,8 +358,10 @@ const executeRoute = async (
360
358
  response.setMode('stream')
361
359
  response.header('Content-Type', 'text/event-stream')
362
360
  response.header('Cache-Control', 'no-cache')
361
+ let sseState: unknown
362
+ const channelId = createWeakUID()
363
363
  channel = {
364
- channelId: requestId,
364
+ channelId,
365
365
  openingData: await data(),
366
366
  send: (data: any) => {
367
367
  response.arrayBuffer(isSerializable(data) ? JSON.stringify(data) : data)
@@ -374,6 +374,34 @@ const executeRoute = async (
374
374
  response.close?.()
375
375
  },
376
376
  state: 'open',
377
+ setState: (s) => {
378
+ sseState = s
379
+ },
380
+ getState: () => sseState as any,
381
+ clearState: () => {
382
+ sseState = undefined
383
+ },
384
+ }
385
+
386
+ // Register the SSE channel with the eventHub (if present) so that
387
+ // eventHub.publish() can deliver messages to SSE subscribers.
388
+ // The channel handler wraps the SSE channel to match PikkuChannelHandler.
389
+ if (singletonServices.eventHub?.onChannelOpened) {
390
+ const channelRef = channel
391
+ const channelHandler = {
392
+ getChannel: () => channelRef,
393
+ send: (data: unknown, isBinary?: boolean) => {
394
+ if (isBinary) channelRef.sendBinary(data as any)
395
+ else channelRef.send(data)
396
+ },
397
+ sendBinary: (data: any) => channelRef.sendBinary(data),
398
+ }
399
+ singletonServices.eventHub.onChannelOpened(channelHandler)
400
+ const originalClose = channel.close
401
+ channel.close = () => {
402
+ singletonServices.eventHub.onChannelClosed(channelId)
403
+ originalClose()
404
+ }
377
405
  }
378
406
  }
379
407
 
@@ -387,27 +415,49 @@ const executeRoute = async (
387
415
  hasSessionChanged: () => userSession.sessionChanged,
388
416
  }
389
417
 
390
- result = await runPikkuFunc(
391
- 'http',
392
- `${meta.method}:${meta.route}`,
393
- meta.pikkuFuncId,
394
- {
395
- singletonServices,
396
- createWireServices,
397
- auth: route.auth !== false,
398
- data,
399
- inheritedMiddleware: meta.middleware,
400
- wireMiddleware: route.middleware,
401
- inheritedPermissions: meta.permissions,
402
- wirePermissions: route.permissions,
403
- coerceDataFromSchema: options.coerceDataFromSchema,
404
- tags: route.tags,
405
- wire,
406
- sessionService: userSession,
407
- packageName: meta.packageName,
418
+ try {
419
+ result = await runPikkuFunc(
420
+ 'http',
421
+ `${meta.method}:${meta.route}`,
422
+ meta.pikkuFuncId,
423
+ {
424
+ singletonServices,
425
+ createWireServices,
426
+ auth: route.auth !== false,
427
+ data,
428
+ inheritedMiddleware: meta.middleware,
429
+ wireMiddleware: route.middleware,
430
+ inheritedPermissions: meta.permissions,
431
+ wirePermissions: route.permissions,
432
+ coerceDataFromSchema: options.coerceDataFromSchema,
433
+ tags: route.tags,
434
+ wire,
435
+ sessionService: userSession,
436
+ packageName: meta.packageName,
437
+ }
438
+ )
439
+ } catch (e: any) {
440
+ if (matchedRoute.route.sse) {
441
+ singletonServices.logger.error(e instanceof Error ? e.message : e)
442
+ try {
443
+ const errorResponse = getErrorResponse(e)
444
+ http?.response?.arrayBuffer(
445
+ JSON.stringify({
446
+ type: 'error',
447
+ errorText: errorResponse?.message ?? 'Internal server error',
448
+ })
449
+ )
450
+ http?.response?.arrayBuffer(JSON.stringify({ type: 'done' }))
451
+ } catch {}
452
+ channel?.close()
453
+ return wireServices ? { result, wireServices } : { result }
408
454
  }
409
- )
410
- if (!matchedRoute.route.sse) {
455
+ throw e
456
+ }
457
+ if (matchedRoute.route.sse) {
458
+ // Flush headers after middleware has run so CORS/auth headers are included
459
+ http?.response?.flushHeaders?.()
460
+ } else {
411
461
  if (result instanceof Response) {
412
462
  await applyWebResponse(http!.response!, result)
413
463
  } else if (result === undefined || result === null) {
@@ -567,36 +617,16 @@ export const fetchData = async <In, Out>(
567
617
 
568
618
  return result
569
619
  } catch (e: any) {
570
- if (matchedRoute?.route.sse) {
571
- // For SSE routes, send error through the stream since the response is already in stream mode
572
- scopedLogger.error(e instanceof Error ? e.message : e)
573
- try {
574
- const errorResponse = getErrorResponse(e)
575
- response.arrayBuffer(
576
- JSON.stringify({
577
- type: 'error',
578
- errorText: errorResponse?.message ?? 'Internal server error',
579
- })
580
- )
581
- response.arrayBuffer(JSON.stringify({ type: 'done' }))
582
- } catch (streamErr: any) {
583
- scopedLogger.error(
584
- `SSE error while sending error payload: ${streamErr instanceof Error ? streamErr.message : String(streamErr)}`
585
- )
586
- }
587
- response.close?.()
588
- } else {
589
- handleHTTPError(
590
- e,
591
- http,
592
- requestId,
593
- scopedLogger,
594
- logWarningsForStatusCodes,
595
- respondWith404,
596
- bubbleErrors,
597
- exposeErrors
598
- )
599
- }
620
+ handleHTTPError(
621
+ e,
622
+ http,
623
+ requestId,
624
+ scopedLogger,
625
+ logWarningsForStatusCodes,
626
+ respondWith404,
627
+ bubbleErrors,
628
+ exposeErrors
629
+ )
600
630
  } finally {
601
631
  // Clean up any session-specific services created during processing
602
632
  if (wireServices) {
@@ -116,13 +116,8 @@ export type CoreHTTPFunctionWiring<
116
116
  In,
117
117
  Out,
118
118
  R extends string,
119
- PikkuFunction extends CorePikkuFunction<
120
- In,
121
- Out,
122
- any,
123
- any,
124
- any
125
- > = CorePikkuFunction<In, Out>,
119
+ PikkuFunction extends CorePikkuFunction<In, Out, any, any, any> =
120
+ CorePikkuFunction<In, Out>,
126
121
  PikkuFunctionSessionless extends CorePikkuFunctionSessionless<
127
122
  In,
128
123
  Out,
@@ -130,15 +125,10 @@ export type CoreHTTPFunctionWiring<
130
125
  any,
131
126
  any
132
127
  > = CorePikkuFunctionSessionless<In, Out>,
133
- PikkuPermission extends CorePikkuPermission<
134
- In,
135
- any,
136
- any
137
- > = CorePikkuPermission<In, any, any>,
138
- PikkuMiddleware extends CorePikkuMiddleware<
139
- any,
140
- any
141
- > = CorePikkuMiddleware<any>,
128
+ PikkuPermission extends CorePikkuPermission<In, any, any> =
129
+ CorePikkuPermission<In, any, any>,
130
+ PikkuMiddleware extends CorePikkuMiddleware<any, any> =
131
+ CorePikkuMiddleware<any>,
142
132
  > =
143
133
  | (CoreHTTPFunction & {
144
134
  route: R
@@ -290,6 +280,7 @@ export interface PikkuHTTPResponse<Out = unknown> {
290
280
  redirect(location: string, status?: number): this
291
281
  close?: () => void
292
282
  setMode?: (mode: 'stream') => void
283
+ flushHeaders?: () => void
293
284
  }
294
285
 
295
286
  /**
@@ -301,11 +292,8 @@ export type HTTPRouteConfig<
301
292
  | CorePikkuFunctionSessionless<any, any, any, any, any> =
302
293
  | CorePikkuFunction<any, any, any, any, any>
303
294
  | CorePikkuFunctionSessionless<any, any, any, any, any>,
304
- PikkuPermission extends CorePikkuPermission<
305
- any,
306
- any,
307
- any
308
- > = CorePikkuPermission<any>,
295
+ PikkuPermission extends CorePikkuPermission<any, any, any> =
296
+ CorePikkuPermission<any>,
309
297
  PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<
310
298
  any,
311
299
  any
@@ -331,11 +319,8 @@ export type HTTPRouteConfig<
331
319
  * Group-level configuration applied to all routes
332
320
  */
333
321
  export type HTTPRoutesGroupConfig<
334
- PikkuPermission extends CorePikkuPermission<
335
- any,
336
- any,
337
- any
338
- > = CorePikkuPermission<any>,
322
+ PikkuPermission extends CorePikkuPermission<any, any, any> =
323
+ CorePikkuPermission<any>,
339
324
  PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<
340
325
  any,
341
326
  any
@@ -8,9 +8,9 @@ import { UnprocessableContentError } from '../../errors/errors.js'
8
8
  * @template In - The type of the request body.
9
9
  * @group RequestResponse
10
10
  */
11
- export class PikkuFetchHTTPRequest<In = unknown>
12
- implements PikkuHTTPRequest<In>
13
- {
11
+ export class PikkuFetchHTTPRequest<
12
+ In = unknown,
13
+ > implements PikkuHTTPRequest<In> {
14
14
  #cookies: Partial<Record<string, string>> | undefined
15
15
  #params: Partial<Record<string, string | string[]>> = {}
16
16
  #url: URL
@@ -105,7 +105,11 @@ export class PikkuFetchHTTPRequest<In = unknown>
105
105
  const merged: Record<string, unknown> = {}
106
106
  for (const part of parts) {
107
107
  for (const [key, value] of Object.entries(part)) {
108
- if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
108
+ if (
109
+ key === '__proto__' ||
110
+ key === 'constructor' ||
111
+ key === 'prototype'
112
+ ) {
109
113
  continue
110
114
  }
111
115
  if (key in merged && !valuesAreEquivalent(merged[key], value)) {
@@ -79,4 +79,45 @@ describe('PikkuFetchHTTPResponse', () => {
79
79
  assert.strictEqual(res.status, 301)
80
80
  assert.strictEqual(res.headers.get('Location'), '/login')
81
81
  })
82
+
83
+ test('redirect rejects invalid redirect locations', () => {
84
+ assert.throws(
85
+ () => new PikkuFetchHTTPResponse().redirect('login'),
86
+ /Invalid redirect location/
87
+ )
88
+ })
89
+
90
+ test('send stores raw body content', async () => {
91
+ const res = new PikkuFetchHTTPResponse().send('plain-body').toResponse()
92
+
93
+ assert.strictEqual(await res.text(), 'plain-body')
94
+ })
95
+
96
+ test('stream mode writes json/text/html/arrayBuffer and close completes the stream', async () => {
97
+ const res = new PikkuFetchHTTPResponse()
98
+ res.setMode('stream')
99
+ res.json({ hello: 'world' })
100
+ res.text('plain')
101
+ res.html('<p>html</p>')
102
+ res.arrayBuffer('bytes')
103
+ res.close()
104
+
105
+ const response = res.toResponse()
106
+ const text = await response.text()
107
+
108
+ assert.ok(text.startsWith(':\n\n'))
109
+ assert.ok(text.includes('data: {"hello":"world"}\n\n'))
110
+ assert.ok(text.includes('data: plain\n\n'))
111
+ assert.ok(text.includes('data: <p>html</p>\n\n'))
112
+ assert.ok(text.includes('data: bytes\n\n'))
113
+ })
114
+
115
+ test('toResponse preserves the instance status over response init args', () => {
116
+ const res = new PikkuFetchHTTPResponse()
117
+ .status(201)
118
+ .json({ ok: true })
119
+ .toResponse({ status: 202 })
120
+
121
+ assert.strictEqual(res.status, 201)
122
+ })
82
123
  })