@pikku/core 0.6.23 → 0.6.24

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 (52) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/channel/channel-runner.d.ts +1 -1
  3. package/dist/channel/channel-runner.js +1 -1
  4. package/dist/channel/local/local-channel-runner.js +5 -5
  5. package/dist/channel/serverless/serverless-channel-runner.js +4 -4
  6. package/dist/http/http-route-runner.js +9 -8
  7. package/dist/http/pikku-fetch-http-request.d.ts +1 -0
  8. package/dist/http/pikku-fetch-http-request.js +64 -8
  9. package/dist/http/pikku-fetch-http-response.d.ts +2 -1
  10. package/dist/http/pikku-fetch-http-response.js +5 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.js +1 -0
  13. package/dist/middleware/auth-apikey.js +2 -2
  14. package/dist/middleware/auth-bearer.js +2 -2
  15. package/dist/middleware/auth-cookie.d.ts +7 -3
  16. package/dist/middleware/auth-cookie.js +34 -17
  17. package/dist/middleware-runner.d.ts +3 -3
  18. package/dist/middleware-runner.js +2 -2
  19. package/dist/services/jwt-service.d.ts +2 -1
  20. package/dist/services/local-secrets.d.ts +2 -2
  21. package/dist/services/local-secrets.js +5 -5
  22. package/dist/services/user-session-service.d.ts +2 -0
  23. package/dist/services/user-session-service.js +3 -0
  24. package/dist/time-utils.d.ts +12 -0
  25. package/dist/time-utils.js +18 -0
  26. package/dist/types/core.types.d.ts +5 -5
  27. package/dist/types/functions.types.d.ts +1 -1
  28. package/lcov.info +1751 -1018
  29. package/package.json +1 -1
  30. package/src/channel/channel-runner.ts +2 -2
  31. package/src/channel/local/local-channel-runner.test.ts +0 -2
  32. package/src/channel/local/local-channel-runner.ts +5 -5
  33. package/src/channel/serverless/serverless-channel-runner.ts +4 -7
  34. package/src/http/http-route-runner.test.ts +1 -1
  35. package/src/http/http-route-runner.ts +18 -10
  36. package/src/http/pikku-fetch-http-request.test.ts +237 -0
  37. package/src/http/pikku-fetch-http-request.ts +64 -8
  38. package/src/http/pikku-fetch-http-response.test.ts +82 -0
  39. package/src/http/pikku-fetch-http-response.ts +16 -2
  40. package/src/index.ts +1 -1
  41. package/src/middleware/auth-apikey.ts +2 -2
  42. package/src/middleware/auth-bearer.ts +2 -2
  43. package/src/middleware/auth-cookie.ts +51 -21
  44. package/src/middleware-runner.ts +3 -3
  45. package/src/services/jwt-service.ts +6 -1
  46. package/src/services/local-secrets.ts +3 -3
  47. package/src/services/user-session-service.ts +4 -0
  48. package/src/time-utils.test.ts +56 -0
  49. package/src/time-utils.ts +32 -0
  50. package/src/types/core.types.ts +5 -5
  51. package/src/types/functions.types.ts +1 -1
  52. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.6.23",
3
+ "version": "0.6.24",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -60,7 +60,7 @@ export const openChannel = async ({
60
60
  request,
61
61
  }: Pick<CoreAPIChannel<unknown, string>, 'route'> &
62
62
  RunChannelParams<unknown> & {
63
- userSessionService: UserSessionService<any>
63
+ userSession: UserSessionService<any>
64
64
  } & RunChannelOptions): Promise<{
65
65
  openingData: unknown
66
66
  channelConfig: CoreAPIChannel<unknown, any>
@@ -88,7 +88,7 @@ export const openChannel = async ({
88
88
  }
89
89
  await validateSchema(
90
90
  singletonServices.logger,
91
- singletonServices.schemaService,
91
+ singletonServices.schema,
92
92
  schemaName,
93
93
  openingData
94
94
  )
@@ -149,8 +149,6 @@ test('runChannel should return a channel handler if channel matches and no auth
149
149
  createSessionServices: mockCreateSessionServices,
150
150
  })
151
151
 
152
- console.log(result)
153
-
154
152
  assert.ok(result, 'Should return a PikkuChannelHandler instance')
155
153
 
156
154
  // Simulate opening the channel
@@ -32,7 +32,7 @@ export const runLocalChannel = async ({
32
32
  let sessionServices: SessionServices<typeof singletonServices> | undefined
33
33
 
34
34
  let channelHandler: PikkuLocalChannelHandler | undefined
35
- const userSessionService = new PikkuUserSessionService()
35
+ const userSession = new PikkuUserSessionService()
36
36
 
37
37
  let http: PikkuHTTP | undefined
38
38
  if (request) {
@@ -52,7 +52,7 @@ export const runLocalChannel = async ({
52
52
  singletonServices,
53
53
  skipUserSession,
54
54
  coerceToArray,
55
- userSessionService,
55
+ userSession,
56
56
  })
57
57
 
58
58
  channelHandler = new PikkuLocalChannelHandler(
@@ -61,7 +61,7 @@ export const runLocalChannel = async ({
61
61
  openingData
62
62
  )
63
63
  const channel = channelHandler.getChannel()
64
- const session = await userSessionService.get()
64
+ const session = await userSession.get()
65
65
  if (createSessionServices) {
66
66
  sessionServices = await createSessionServices(
67
67
  singletonServices,
@@ -73,7 +73,7 @@ export const runLocalChannel = async ({
73
73
  const allServices = {
74
74
  ...singletonServices,
75
75
  ...sessionServices,
76
- userSession: userSessionService,
76
+ userSession: userSession,
77
77
  }
78
78
 
79
79
  channelHandler.registerOnOpen(() => {
@@ -115,7 +115,7 @@ export const runLocalChannel = async ({
115
115
  await runMiddleware(
116
116
  {
117
117
  ...singletonServices,
118
- userSessionService,
118
+ userSession,
119
119
  },
120
120
  { http },
121
121
  route.middleware || [],
@@ -78,10 +78,7 @@ export const runChannelConnect = async ({
78
78
  http = createHTTPInteraction(new PikkuFetchHTTPRequest(request), response)
79
79
  }
80
80
 
81
- const userSessionService = new PikkuUserSessionService(
82
- channelStore,
83
- channelId
84
- )
81
+ const userSession = new PikkuUserSessionService(channelStore, channelId)
85
82
 
86
83
  const { channelConfig, openingData } = await openChannel({
87
84
  channelId,
@@ -90,7 +87,7 @@ export const runChannelConnect = async ({
90
87
  route,
91
88
  singletonServices,
92
89
  coerceToArray,
93
- userSessionService,
90
+ userSession,
94
91
  })
95
92
 
96
93
  const main = async () => {
@@ -110,7 +107,7 @@ export const runChannelConnect = async ({
110
107
  sessionServices = await createSessionServices(
111
108
  singletonServices,
112
109
  { http },
113
- await userSessionService.get()
110
+ await userSession.get()
114
111
  )
115
112
  }
116
113
  await channelConfig.onConnect?.(
@@ -138,7 +135,7 @@ export const runChannelConnect = async ({
138
135
  await runMiddleware(
139
136
  {
140
137
  ...singletonServices,
141
- userSessionService,
138
+ userSession,
142
139
  },
143
140
  { http },
144
141
  channelConfig.middleware || [],
@@ -10,7 +10,7 @@ import {
10
10
  } from '../channel/local/local-channel-runner.test.js'
11
11
 
12
12
  const sessionMiddleware: PikkuMiddleware = async (services, _, next) => {
13
- services.userSessionService.set({ userId: 'test' } as any)
13
+ services.userSession.set({ userId: 'test' } as any)
14
14
  await next()
15
15
  }
16
16
 
@@ -7,7 +7,11 @@ import {
7
7
  PikkuHTTPRequest,
8
8
  PikkuHTTPResponse,
9
9
  } from './http-routes.types.js'
10
- import { PikkuMiddleware, SessionServices } from '../types/core.types.js'
10
+ import {
11
+ CoreUserSession,
12
+ PikkuMiddleware,
13
+ SessionServices,
14
+ } from '../types/core.types.js'
11
15
  import { match } from 'path-to-regexp'
12
16
  import {
13
17
  ForbiddenError,
@@ -16,7 +20,10 @@ import {
16
20
  } from '../errors/errors.js'
17
21
  import { closeSessionServices } from '../utils.js'
18
22
  import { coerceQueryStringToArray, validateSchema } from '../schema.js'
19
- import { PikkuUserSessionService } from '../services/user-session-service.js'
23
+ import {
24
+ PikkuUserSessionService,
25
+ UserSessionService,
26
+ } from '../services/user-session-service.js'
20
27
  import { runMiddleware } from '../middleware-runner.js'
21
28
  import { handleError } from '../handle-error.js'
22
29
  import { pikkuState } from '../pikku-state.js'
@@ -195,7 +202,7 @@ export const createHTTPInteraction = (
195
202
  const executeRouteWithMiddleware = async (
196
203
  services: {
197
204
  singletonServices: any
198
- userSessionService: any
205
+ userSession: UserSessionService<CoreUserSession>
199
206
  createSessionServices: Function
200
207
  skipUserSession: boolean
201
208
  },
@@ -214,7 +221,7 @@ const executeRouteWithMiddleware = async (
214
221
  const { matchedPath, params, route, middleware, schemaName } = matchedRoute
215
222
  const {
216
223
  singletonServices,
217
- userSessionService,
224
+ userSession,
218
225
  createSessionServices,
219
226
  skipUserSession,
220
227
  } = services
@@ -232,7 +239,7 @@ const executeRouteWithMiddleware = async (
232
239
 
233
240
  // Main route execution logic wrapped for middleware handling
234
241
  const runMain = async () => {
235
- const session = userSessionService.get()
242
+ const session = userSession.get()
236
243
 
237
244
  // Ensure session is available when required
238
245
  if (skipUserSession && requiresSession) {
@@ -251,7 +258,7 @@ const executeRouteWithMiddleware = async (
251
258
 
252
259
  // Create session-specific services for handling the request
253
260
  sessionServices = await createSessionServices(
254
- { ...singletonServices, userSessionService },
261
+ { ...singletonServices, userSession },
255
262
  { http },
256
263
  session
257
264
  )
@@ -259,6 +266,7 @@ const executeRouteWithMiddleware = async (
259
266
  const allServices = {
260
267
  ...singletonServices,
261
268
  ...sessionServices,
269
+ userSession,
262
270
  http,
263
271
  }
264
272
  const data = await http?.request?.data()
@@ -266,7 +274,7 @@ const executeRouteWithMiddleware = async (
266
274
  // Validate request data against the defined schema, if any
267
275
  await validateSchema(
268
276
  singletonServices.logger,
269
- singletonServices.schemaService,
277
+ singletonServices.schema,
270
278
  schemaName,
271
279
  data
272
280
  )
@@ -307,7 +315,7 @@ const executeRouteWithMiddleware = async (
307
315
 
308
316
  // Execute middleware, then run the main logic
309
317
  await runMiddleware(
310
- { ...singletonServices, userSessionService },
318
+ { ...singletonServices, userSession },
311
319
  { http },
312
320
  middleware,
313
321
  runMain
@@ -392,7 +400,7 @@ export const fetchData = async <In, Out>(
392
400
  bubbleErrors = false,
393
401
  }: RunRouteOptions & RunRouteParams
394
402
  ): Promise<Out | void> => {
395
- const userSessionService = new PikkuUserSessionService()
403
+ const userSession = new PikkuUserSessionService()
396
404
  let sessionServices: SessionServices<typeof singletonServices> | undefined
397
405
  let result: Out
398
406
 
@@ -422,7 +430,7 @@ export const fetchData = async <In, Out>(
422
430
  ;({ result, sessionServices } = await executeRouteWithMiddleware(
423
431
  {
424
432
  singletonServices,
425
- userSessionService,
433
+ userSession,
426
434
  createSessionServices,
427
435
  skipUserSession,
428
436
  },
@@ -0,0 +1,237 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { PikkuFetchHTTPRequest } from './pikku-fetch-http-request'
4
+
5
+ const createRequest = (method, url, body, headers = {}) => {
6
+ return new Request(url, {
7
+ method,
8
+ headers,
9
+ body:
10
+ method === 'POST'
11
+ ? typeof body === 'string'
12
+ ? body
13
+ : JSON.stringify(body)
14
+ : undefined,
15
+ })
16
+ }
17
+
18
+ test('method() returns lowercase HTTP method', () => {
19
+ const req = createRequest('POST', 'http://localhost/test', null)
20
+ const pikkuReq = new PikkuFetchHTTPRequest(req)
21
+ assert.equal(pikkuReq.method(), 'post')
22
+ })
23
+
24
+ test('path() returns pathname only', () => {
25
+ const req = createRequest('GET', 'http://localhost/foo/bar?x=1', null)
26
+ const pikkuReq = new PikkuFetchHTTPRequest(req)
27
+ assert.equal(pikkuReq.path(), '/foo/bar')
28
+ })
29
+
30
+ test('header() retrieves headers case-insensitively', () => {
31
+ const req = createRequest('GET', 'http://localhost', null, {
32
+ 'Content-Type': 'application/json',
33
+ })
34
+ const pikkuReq = new PikkuFetchHTTPRequest(req)
35
+ assert.equal(pikkuReq.header('content-type'), 'application/json')
36
+ })
37
+
38
+ test('cookie() parses cookies correctly', () => {
39
+ const req = createRequest('GET', 'http://localhost', null, {
40
+ Cookie: 'session=abc; user=test',
41
+ })
42
+ const pikkuReq = new PikkuFetchHTTPRequest(req)
43
+ assert.equal(pikkuReq.cookie('session'), 'abc')
44
+ assert.equal(pikkuReq.cookie('user'), 'test')
45
+ assert.equal(pikkuReq.cookie('missing'), null)
46
+ })
47
+
48
+ test('params() and setParams()', () => {
49
+ const req = createRequest('GET', 'http://localhost', null)
50
+ const pikkuReq = new PikkuFetchHTTPRequest(req)
51
+ assert.deepEqual(pikkuReq.params(), {})
52
+ pikkuReq.setParams({ id: '123' })
53
+ assert.deepEqual(pikkuReq.params(), { id: '123' })
54
+ })
55
+
56
+ test('query() parses URL search params', () => {
57
+ const req = createRequest('GET', 'http://localhost?x=1&y=2', null)
58
+ const pikkuReq = new PikkuFetchHTTPRequest(req)
59
+ assert.equal(pikkuReq.query().x, '1')
60
+ assert.equal(pikkuReq.query().y, '2')
61
+ })
62
+
63
+ test('data() merges json body, query, and params', async () => {
64
+ const req = createRequest(
65
+ 'POST',
66
+ 'http://localhost/test?a=5',
67
+ { foo: 'bar' },
68
+ { 'Content-Type': 'application/json' }
69
+ )
70
+ const pikkuReq = new PikkuFetchHTTPRequest(req)
71
+ pikkuReq.setParams({ id: '22' })
72
+ const result = await pikkuReq.data()
73
+ assert.deepEqual(result, { id: '22', a: '5', foo: 'bar' })
74
+ })
75
+
76
+ test('data() wraps string JSON body under data key', async () => {
77
+ const req = createRequest('POST', 'http://localhost', '"hello"', {
78
+ 'Content-Type': 'application/json',
79
+ })
80
+ const pikkuReq = new PikkuFetchHTTPRequest(req)
81
+ const result = await pikkuReq.data()
82
+ assert.deepEqual(result, { data: 'hello' })
83
+ })
84
+
85
+ test('data() handles text/plain correctly', async () => {
86
+ const req = new Request('http://localhost', {
87
+ method: 'POST',
88
+ headers: { 'Content-Type': 'text/plain' },
89
+ body: 'hello world',
90
+ })
91
+ const pikkuReq = new PikkuFetchHTTPRequest(req)
92
+ const result = await pikkuReq.data()
93
+ assert.deepEqual(result, { data: 'hello world' })
94
+ })
95
+
96
+ test('data() returns arrayBuffer for unknown content-type', async () => {
97
+ const buffer = Buffer.from('raw')
98
+ const req = new Request('http://localhost', {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/octet-stream' },
101
+ body: buffer,
102
+ })
103
+ const pikkuReq = new PikkuFetchHTTPRequest<any>(req)
104
+ const result = await pikkuReq.data()
105
+ assert(result.data instanceof ArrayBuffer)
106
+ const str = Buffer.from(result.data).toString()
107
+ assert.equal(str, 'raw')
108
+ })
109
+
110
+ test('data() handles invalid JSON safely', async () => {
111
+ const req = new Request('http://localhost', {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: 'not-json',
115
+ })
116
+ const pikkuReq = new PikkuFetchHTTPRequest(req)
117
+ try {
118
+ await pikkuReq.data()
119
+ assert(false, 'Should have thrown')
120
+ } catch (e) {
121
+ assert(e.message.includes('Error parsing body'))
122
+ }
123
+ })
124
+
125
+ test('data() parses application/x-www-form-urlencoded correctly', async () => {
126
+ const formBody = 'name=Yasser&age=35&active=true'
127
+
128
+ const req = new Request('http://localhost/form?ref=abc', {
129
+ method: 'POST',
130
+ headers: {
131
+ 'Content-Type': 'application/x-www-form-urlencoded',
132
+ },
133
+ body: formBody,
134
+ })
135
+
136
+ const pikkuReq = new PikkuFetchHTTPRequest(req)
137
+ pikkuReq.setParams({ userId: '999' })
138
+
139
+ const result = await pikkuReq.data()
140
+
141
+ assert.deepEqual(result, {
142
+ userId: '999',
143
+ ref: 'abc',
144
+ name: 'Yasser',
145
+ age: '35',
146
+ active: 'true',
147
+ })
148
+ })
149
+
150
+ // --- Compatible types
151
+
152
+ test('data() treats "123" and 123 as equivalent', async () => {
153
+ const req = createRequest(
154
+ 'POST',
155
+ 'http://localhost?id=123',
156
+ { id: 123 },
157
+ {
158
+ 'Content-Type': 'application/json',
159
+ }
160
+ )
161
+
162
+ const r = new PikkuFetchHTTPRequest(req)
163
+ r.setParams({ id: '123' }) // All match
164
+ const result = await r.data()
165
+ assert.deepEqual(result, { id: '123' })
166
+ })
167
+
168
+ test('data() treats "true" and true as equivalent', async () => {
169
+ const req = createRequest(
170
+ 'POST',
171
+ 'http://localhost?flag=true',
172
+ { flag: true },
173
+ {
174
+ 'Content-Type': 'application/json',
175
+ }
176
+ )
177
+
178
+ const r = new PikkuFetchHTTPRequest(req)
179
+ r.setParams({ flag: 'true' }) // All match
180
+ const result = await r.data()
181
+ assert.deepEqual(result, { flag: 'true' })
182
+ })
183
+
184
+ // --- Conflicts
185
+
186
+ test('data() throws on conflicting values', async () => {
187
+ const req = createRequest(
188
+ 'POST',
189
+ 'http://localhost?foo=123',
190
+ { foo: 456 },
191
+ {
192
+ 'Content-Type': 'application/json',
193
+ }
194
+ )
195
+
196
+ const r = new PikkuFetchHTTPRequest(req)
197
+ r.setParams({ foo: '123' })
198
+
199
+ await assert.rejects(async () => await r.data(), {
200
+ message: 'Conflicting values for key "foo": "123" vs "456"',
201
+ })
202
+ })
203
+
204
+ test('data() throws on boolean conflict', async () => {
205
+ const req = createRequest(
206
+ 'POST',
207
+ 'http://localhost?debug=false',
208
+ { debug: true },
209
+ {
210
+ 'Content-Type': 'application/json',
211
+ }
212
+ )
213
+
214
+ const r = new PikkuFetchHTTPRequest(req)
215
+ r.setParams({})
216
+
217
+ await assert.rejects(async () => await r.data(), {
218
+ message: 'Conflicting values for key "debug": "false" vs "true"',
219
+ })
220
+ })
221
+
222
+ // --- Safe fallback: only one source
223
+
224
+ test('data() works when only body has values', async () => {
225
+ const req = createRequest(
226
+ 'POST',
227
+ 'http://localhost',
228
+ { test: 'ok' },
229
+ {
230
+ 'Content-Type': 'application/json',
231
+ }
232
+ )
233
+
234
+ const r = new PikkuFetchHTTPRequest(req)
235
+ const result = await r.data()
236
+ assert.deepEqual(result, { test: 'ok' })
237
+ })
@@ -5,6 +5,7 @@ import {
5
5
  PikkuHTTPRequest,
6
6
  PikkuQuery,
7
7
  } from './http-routes.types.js'
8
+ import { UnprocessableContentError } from '../errors/errors.js'
8
9
 
9
10
  /**
10
11
  * Abstract class representing a pikku request.
@@ -96,16 +97,71 @@ export class PikkuFetchHTTPRequest<In = unknown>
96
97
  * @returns A promise that resolves to an object containing the combined data.
97
98
  */
98
99
  public async data(): Promise<In> {
100
+ const body = await this.body()
101
+ const parts = [this.params(), this.query(), body]
102
+ const merged: Record<string, unknown> = {}
103
+ for (const part of parts) {
104
+ for (const [key, value] of Object.entries(part)) {
105
+ if (key in merged && !valuesAreEquivalent(merged[key], value)) {
106
+ throw new UnprocessableContentError(
107
+ `Conflicting values for key "${key}": "${merged[key]}" vs "${value}"`
108
+ )
109
+ }
110
+ merged[key] ??= value
111
+ }
112
+ }
113
+ return merged as In
114
+ }
115
+
116
+ private async body(): Promise<any> {
117
+ const noBodyMethods: HTTPMethod[] = ['get', 'head', 'options']
118
+ if (noBodyMethods.includes(this.method())) {
119
+ return {}
120
+ }
121
+
99
122
  let body: any = {}
123
+ const contentType = this.header('content-type') || ''
100
124
  try {
101
- body = await this.json()
102
- } catch (e) {}
103
-
104
- return {
105
- ...this.params(),
106
- ...this.query(),
107
- // TODO: If body isn't an object, we should insert it as the word...
108
- ...body,
125
+ if (contentType.includes('application/json')) {
126
+ const parsed = await this.json()
127
+ body =
128
+ typeof parsed === 'object' &&
129
+ parsed !== null &&
130
+ !Array.isArray(parsed)
131
+ ? parsed
132
+ : { data: parsed }
133
+ } else if (contentType.includes('text/')) {
134
+ const text = await this.request.text()
135
+ body = { data: text }
136
+ } else if (contentType.includes('application/octet-stream')) {
137
+ const buffer = await this.request.arrayBuffer()
138
+ body = { data: buffer }
139
+ } else if (contentType === 'application/x-www-form-urlencoded') {
140
+ const text = await this.request.text()
141
+ body = Object.fromEntries(new URLSearchParams(text))
142
+ } else {
143
+ throw new UnprocessableContentError(
144
+ `Unsupported content type ${contentType}`
145
+ )
146
+ }
147
+ } catch (e) {
148
+ throw new UnprocessableContentError(`Error parsing body: ${e}`)
109
149
  }
150
+ return body
151
+ }
152
+ }
153
+
154
+ function valuesAreEquivalent(a: unknown, b: unknown): boolean {
155
+ return coerce(a) === coerce(b)
156
+ }
157
+
158
+ function coerce(value: unknown): string | number | boolean {
159
+ if (typeof value === 'boolean' || typeof value === 'number') return value
160
+ if (typeof value === 'string') {
161
+ if (value === 'true') return true
162
+ if (value === 'false') return false
163
+ const num = Number(value)
164
+ return isNaN(num) ? value : num
110
165
  }
166
+ return value as any
111
167
  }
@@ -0,0 +1,82 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'assert'
3
+ import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js' // Adjust path if needed
4
+
5
+ describe('PikkuFetchHTTPResponse', () => {
6
+ test('sets status code', () => {
7
+ const res = new PikkuFetchHTTPResponse().status(404).toResponse()
8
+ assert.strictEqual(res.status, 404)
9
+ })
10
+
11
+ test('sets headers', async () => {
12
+ const res = new PikkuFetchHTTPResponse()
13
+ .header('X-Test', 'value')
14
+ .header('X-Multi', ['a', 'b'])
15
+ .toResponse()
16
+
17
+ assert.strictEqual(res.headers.get('X-Test'), 'value')
18
+ const multi = res.headers.get('X-Multi')?.split(', ')
19
+ assert.deepEqual(multi, ['a', 'b'])
20
+ })
21
+
22
+ test('sets cookies', () => {
23
+ const res = new PikkuFetchHTTPResponse()
24
+ .cookie('token', 'abc123', { httpOnly: true, path: '/' })
25
+ .cookie('other', 'val', { maxAge: 3600 })
26
+ .toResponse()
27
+
28
+ const setCookieHeader = res.headers.get('Set-Cookie')
29
+ assert.ok(setCookieHeader)
30
+
31
+ const cookies = setCookieHeader!.split(', ')
32
+ assert.ok(cookies.some((c) => c.startsWith('token=abc123')))
33
+ assert.ok(cookies.some((c) => c.startsWith('other=val')))
34
+ })
35
+
36
+ test('json sets correct content type and body', async () => {
37
+ const res = new PikkuFetchHTTPResponse()
38
+ .json({ hello: 'world' })
39
+ .toResponse()
40
+
41
+ assert.strictEqual(res.headers.get('Content-Type'), 'application/json')
42
+ const body = await res.json()
43
+ assert.deepEqual(body, { hello: 'world' })
44
+ })
45
+
46
+ test('text sets correct content type and body', async () => {
47
+ const res = new PikkuFetchHTTPResponse().text('hello').toResponse()
48
+
49
+ assert.strictEqual(res.headers.get('Content-Type'), 'text/plain')
50
+ const text = await res.text()
51
+ assert.strictEqual(text, 'hello')
52
+ })
53
+
54
+ test('html sets correct content type and body', async () => {
55
+ const res = new PikkuFetchHTTPResponse().html('<p>hi</p>').toResponse()
56
+
57
+ assert.strictEqual(res.headers.get('Content-Type'), 'text/html')
58
+ const text = await res.text()
59
+ assert.strictEqual(text, '<p>hi</p>')
60
+ })
61
+
62
+ test('arrayBuffer sets correct content type', async () => {
63
+ const buffer = new Uint8Array([1, 2, 3]).buffer
64
+ const res = new PikkuFetchHTTPResponse().arrayBuffer(buffer).toResponse()
65
+
66
+ assert.strictEqual(
67
+ res.headers.get('Content-Type'),
68
+ 'application/octet-stream'
69
+ )
70
+ const body = await res.arrayBuffer()
71
+ assert.deepEqual(new Uint8Array(body), new Uint8Array([1, 2, 3]))
72
+ })
73
+
74
+ test('redirect sets Location header and status', () => {
75
+ const res = new PikkuFetchHTTPResponse()
76
+ .redirect('/login', 301)
77
+ .toResponse()
78
+
79
+ assert.strictEqual(res.status, 301)
80
+ assert.strictEqual(res.headers.get('Location'), '/login')
81
+ })
82
+ })
@@ -1,8 +1,14 @@
1
1
  import { PikkuHTTPResponse } from './http-routes.types.js'
2
+ import {
3
+ SerializeOptions as CookieSerializeOptions,
4
+ serialize as serializeCookie,
5
+ } from 'cookie'
2
6
 
3
7
  export class PikkuFetchHTTPResponse implements PikkuHTTPResponse {
4
8
  #statusCode: number = 200
5
9
  #headers = new Headers()
10
+ #cookies = new Map<string, { value: string; flags: CookieSerializeOptions }>()
11
+
6
12
  #body: BodyInit | null = null
7
13
 
8
14
  public status(code: number): this {
@@ -10,8 +16,12 @@ export class PikkuFetchHTTPResponse implements PikkuHTTPResponse {
10
16
  return this
11
17
  }
12
18
 
13
- public cookie(name: string, value: string, flags: any): this {
14
- // TODO
19
+ public cookie(
20
+ name: string,
21
+ value: string,
22
+ flags: CookieSerializeOptions
23
+ ): this {
24
+ this.#cookies.set(name, { value, flags })
15
25
  return this
16
26
  }
17
27
 
@@ -61,6 +71,10 @@ export class PikkuFetchHTTPResponse implements PikkuHTTPResponse {
61
71
  }
62
72
 
63
73
  public toResponse(args?: Record<string, any>): Response {
74
+ const cookieHeader = Array.from(this.#cookies.entries()).map(
75
+ ([name, { value, flags }]) => serializeCookie(name, value, flags)
76
+ )
77
+ this.#headers.set('Set-Cookie', cookieHeader.join(', '))
64
78
  return new Response(this.#body, {
65
79
  ...args,
66
80
  status: this.#statusCode,
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ export * from './channel/index.js'
11
11
  export * from './scheduler/index.js'
12
12
  export * from './errors/index.js'
13
13
  export * from './middleware/index.js'
14
-
14
+ export * from './time-utils.js'
15
15
  export { pikkuState } from './pikku-state.js'
16
16
  export { runMiddleware } from './middleware-runner.js'
17
17
  export { addRoute, addMiddleware } from './http/http-route-runner.js'