galbe 0.1.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.
Binary file
@@ -0,0 +1,8 @@
1
+ {
2
+ "string": "test"
3
+ "number": 3.14,
4
+ "bool": true,
5
+ "arrayStr": ["un", "deux", "trois"],
6
+ "arrayNumber": [0],
7
+ "arrayBool": [true, false]
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "string": "test",
3
+ "number": 3.14,
4
+ "bool": true,
5
+ "arrayStr": ["un", "deux", "trois"],
6
+ "arrayNumber": [0],
7
+ "arrayBool": [true, false]
8
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "string": "test",
3
+ "number": 3.14,
4
+ "bool": true,
5
+ "arrayNumber": [0]
6
+ }
@@ -0,0 +1,38 @@
1
+ import type { Galbe } from '../../src'
2
+ import { T } from '../../src'
3
+
4
+ /**
5
+ * description
6
+ * multiline
7
+ * @tag test
8
+ * ok
9
+ */
10
+ export default (g: Galbe) => {
11
+ /**
12
+ * This part
13
+ * here
14
+ * @tags tag1, tag2, tag3
15
+ * @summary short summary
16
+ * @description longer description example
17
+ * @deprecated
18
+ * @param {path} param1 description
19
+ * @param {query} param2 description
20
+ */
21
+ g.get('/test/:param1', _ => {})
22
+
23
+ // Comment between enpoints
24
+
25
+ /**
26
+ * @tags tag1, tag2, tag3
27
+ * @summary short summary
28
+ * @description longer description example
29
+ * @body body descripton
30
+ */
31
+ g.post('/test', { body: T.Object({ foo: T.String() }) }, _ => {})
32
+
33
+ /**
34
+ * @tags tag1, tag2
35
+ * @other Hello Mom!
36
+ */
37
+ g.put('/test', { body: T.Object({ foo: T.String() }) }, [() => {}], _ => {})
38
+ }
@@ -0,0 +1,9 @@
1
+ import type { Galbe } from '../../src'
2
+
3
+ export default (g: Galbe) => {
4
+ g.get('/one', _ => {})
5
+ // Comment between enpoints
6
+ g.post('/two', _ => {})
7
+
8
+ g.put('/three', _ => {})
9
+ }
@@ -0,0 +1,109 @@
1
+ import { expect, test, describe, beforeAll } from 'bun:test'
2
+ import { Galbe, T } from '../src'
3
+ import { decoder } from './test.utils'
4
+
5
+ const port = 7359
6
+
7
+ const UUID_RGX = '[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'
8
+
9
+ function* genTxt(text: string) {
10
+ const words = text.split(' ')
11
+ for (const w of words) yield w
12
+ }
13
+
14
+ const rsTxt = (text: string) => {
15
+ return new ReadableStream({
16
+ async start(controller) {
17
+ const words = text.split(' ')
18
+ for (const w of words) controller.enqueue(w)
19
+ controller.close()
20
+ }
21
+ })
22
+ }
23
+
24
+ describe('responses', () => {
25
+ beforeAll(async () => {
26
+ const galbe = new Galbe()
27
+
28
+ galbe.post(
29
+ '/response',
30
+ {
31
+ body: T.Optional(T.Object(T.Any())),
32
+ query: { text: T.Optional(T.String()), stream: T.Optional(T.String()) }
33
+ },
34
+ ctx => {
35
+ if (ctx.query.text) {
36
+ if (ctx.query.stream === 'generatorFunction') return genTxt(ctx.query.text)
37
+ if (ctx.query.stream === 'readableStream') return rsTxt(ctx.query.text)
38
+ else return ctx.query.text
39
+ } else if (ctx.body) return ctx.body
40
+
41
+ return null
42
+ }
43
+ )
44
+
45
+ galbe.get('/error', _ => {
46
+ return { next: () => {} }
47
+ })
48
+
49
+ galbe.onError(_ => {
50
+ throw new Error()
51
+ })
52
+
53
+ await galbe.listen(port)
54
+ })
55
+
56
+ test('response, string', async () => {
57
+ const reqTxt = 'Hello Mom!'
58
+ let resp = await fetch(`http://localhost:${port}/response?text=${reqTxt}`, {
59
+ method: 'POST'
60
+ })
61
+
62
+ const body = await resp.text()
63
+
64
+ expect(resp.status).toBe(200)
65
+ expect(resp.headers.get('content-type')).toBe('text/plain')
66
+ expect(body).toBe(reqTxt)
67
+ })
68
+
69
+ test('response, object', async () => {
70
+ const reqBody = { foo: 'bar' }
71
+ let resp = await fetch(`http://localhost:${port}/response`, {
72
+ method: 'POST',
73
+ body: JSON.stringify(reqBody),
74
+ headers: {
75
+ 'content-type': 'application/json'
76
+ }
77
+ })
78
+
79
+ const body = await resp.json()
80
+
81
+ expect(resp.status).toBe(200)
82
+ expect(resp.headers.get('content-type')).toBe('application/json')
83
+ expect(body).toEqual(reqBody)
84
+ })
85
+
86
+ test('response, stream', async () => {
87
+ const reqTxt = 'Hello Mom!'
88
+ const cases = [{ stream: 'generatorFunction' }, { stream: 'readableStream' }]
89
+
90
+ for (const c of cases) {
91
+ let resp = await fetch(`http://localhost:${port}/response?text=${reqTxt}&stream=${c.stream}`, {
92
+ method: 'POST',
93
+ headers: {
94
+ 'content-type': 'application/json'
95
+ }
96
+ })
97
+ const reader = resp.body?.getReader()
98
+ let body = ''
99
+ while (reader) {
100
+ const { value, done } = await reader.read()
101
+ if (done) break
102
+ body += decoder.decode(value)
103
+ }
104
+ expect(resp.status).toBe(200)
105
+ expect(resp.headers.get('content-type')).toBe('text/event-stream')
106
+ expect(body).toMatch(new RegExp(`id:${UUID_RGX}\ndata:Hello\n\nid:${UUID_RGX}\ndata:Mom!\n\n`))
107
+ }
108
+ })
109
+ })
@@ -0,0 +1,209 @@
1
+ import { expect, test, describe } from 'bun:test'
2
+ import { defineRoutes, metaAnalysis } from '../src/routes'
3
+ import { Galbe } from '../src'
4
+
5
+ describe('routeFiles', () => {
6
+ test('meta analysis, empty', async () => {
7
+ let meta = await metaAnalysis('./test/resources/test.route.empty.ts')
8
+ expect(meta).toEqual({
9
+ header: {},
10
+ routes: {
11
+ '/one': {
12
+ get: {}
13
+ },
14
+ '/two': {
15
+ post: {}
16
+ },
17
+ '/three': {
18
+ put: {}
19
+ }
20
+ }
21
+ })
22
+ })
23
+
24
+ test('meta analysis, comments', async () => {
25
+ let meta = await metaAnalysis('./test/resources/test.route.comment.ts')
26
+ expect(meta).toEqual({
27
+ header: {
28
+ head: 'description\nmultiline',
29
+ tag: 'test'
30
+ },
31
+ routes: {
32
+ '/test/:param1': {
33
+ get: {
34
+ head: 'This part\nhere',
35
+ tags: 'tag1, tag2, tag3',
36
+ summary: 'short summary',
37
+ description: 'longer description example',
38
+ deprecated: true,
39
+ param: ['{path} param1 description', '{query} param2 description']
40
+ }
41
+ },
42
+ '/test': {
43
+ post: {
44
+ tags: 'tag1, tag2, tag3',
45
+ summary: 'short summary',
46
+ description: 'longer description example',
47
+ body: 'body descripton'
48
+ },
49
+ put: {
50
+ tags: 'tag1, tag2',
51
+ other: 'Hello Mom!'
52
+ }
53
+ }
54
+ }
55
+ })
56
+ })
57
+
58
+ test('define routes, no route', async () => {
59
+ const k = new Galbe()
60
+ await defineRoutes({}, k)
61
+ expect(k.router.routes).toEqual({
62
+ GET: {},
63
+ POST: {},
64
+ PUT: {},
65
+ PATCH: {},
66
+ DELETE: {},
67
+ OPTIONS: {}
68
+ })
69
+ })
70
+
71
+ test('define routes, no route found', async () => {
72
+ const k = new Galbe()
73
+ await defineRoutes({ routes: 'unexisting_route' }, k)
74
+ expect(k.router.routes).toEqual({
75
+ GET: {},
76
+ POST: {},
77
+ PUT: {},
78
+ PATCH: {},
79
+ DELETE: {},
80
+ OPTIONS: {}
81
+ })
82
+ })
83
+
84
+ test('define routes, route.empty', async () => {
85
+ const k = new Galbe()
86
+
87
+ await defineRoutes({ routes: 'test/resources/test.route.empty.ts' }, k)
88
+
89
+ const r = k.router.routes
90
+ expect(r.GET?.children?.one?.route).toMatchObject({
91
+ method: 'get',
92
+ path: '/one',
93
+ handler: () => {}
94
+ })
95
+ expect(r.POST?.children?.two?.route).toMatchObject({
96
+ method: 'post',
97
+ path: '/two',
98
+ handler: () => {}
99
+ })
100
+ expect(r.PUT?.children?.three?.route).toMatchObject({
101
+ method: 'put',
102
+ path: '/three',
103
+ handler: () => {}
104
+ })
105
+ expect(k.meta).toMatchObject([
106
+ {
107
+ header: {},
108
+ routes: {
109
+ '/one': {
110
+ get: {}
111
+ },
112
+ '/two': {
113
+ post: {}
114
+ },
115
+ '/three': {
116
+ put: {}
117
+ }
118
+ }
119
+ }
120
+ ])
121
+ expect(k.meta?.[0].file).toMatch(/test\.route\.empty\.ts$/)
122
+ })
123
+
124
+ test('define routes, all', async () => {
125
+ const k = new Galbe()
126
+
127
+ await defineRoutes({ routes: ['test/resources/test.route.*.ts'] }, k)
128
+
129
+ const r = k.router.routes
130
+ expect(r.GET?.children?.one?.route).toMatchObject({
131
+ method: 'get',
132
+ path: '/one',
133
+ handler: () => {}
134
+ })
135
+ expect(r.POST?.children?.two?.route).toMatchObject({
136
+ method: 'post',
137
+ path: '/two',
138
+ handler: () => {}
139
+ })
140
+ expect(r.PUT?.children?.three?.route).toMatchObject({
141
+ method: 'put',
142
+ path: '/three',
143
+ handler: () => {}
144
+ })
145
+ expect(r.GET?.children?.test?.param?.route).toMatchObject({
146
+ method: 'get',
147
+ path: '/test/:param1',
148
+ handler: () => {}
149
+ })
150
+ expect(r.POST?.children?.test?.route).toMatchObject({
151
+ method: 'post',
152
+ path: '/test',
153
+ handler: () => {}
154
+ })
155
+ expect(r.PUT?.children?.test?.route).toMatchObject({
156
+ method: 'put',
157
+ path: '/test',
158
+ handler: () => {}
159
+ })
160
+ expect(k.meta?.sort((a, b) => (a.file < b.file ? 1 : -1))).toMatchObject([
161
+ {
162
+ header: {},
163
+ routes: {
164
+ '/one': {
165
+ get: {}
166
+ },
167
+ '/two': {
168
+ post: {}
169
+ },
170
+ '/three': {
171
+ put: {}
172
+ }
173
+ }
174
+ },
175
+ {
176
+ header: {
177
+ head: 'description\nmultiline',
178
+ tag: 'test'
179
+ },
180
+ routes: {
181
+ '/test/:param1': {
182
+ get: {
183
+ head: 'This part\nhere',
184
+ tags: 'tag1, tag2, tag3',
185
+ summary: 'short summary',
186
+ description: 'longer description example',
187
+ deprecated: true,
188
+ param: ['{path} param1 description', '{query} param2 description']
189
+ }
190
+ },
191
+ '/test': {
192
+ post: {
193
+ tags: 'tag1, tag2, tag3',
194
+ summary: 'short summary',
195
+ description: 'longer description example',
196
+ body: 'body descripton'
197
+ },
198
+ put: {
199
+ tags: 'tag1, tag2',
200
+ other: 'Hello Mom!'
201
+ }
202
+ }
203
+ }
204
+ }
205
+ ])
206
+ expect(k.meta?.[0].file).toMatch(/test\.route\..*$/)
207
+ expect(k.meta?.[1].file).toMatch(/test\.route\..*$/)
208
+ })
209
+ })
@@ -0,0 +1,207 @@
1
+ import { expect, test, describe } from 'bun:test'
2
+ import { Galbe, NotFoundError, type RouteNode } from '../src'
3
+
4
+ describe('router', () => {
5
+ test('empty', async () => {
6
+ const galbe = new Galbe()
7
+ const router = galbe.router
8
+
9
+ expect(router.prefix).toBe('')
10
+ expect(router.routes.GET).toEqual({})
11
+ expect(router.routes.POST).toEqual({})
12
+ expect(router.routes.PUT).toEqual({})
13
+ expect(router.routes.PATCH).toEqual({})
14
+ expect(router.routes.DELETE).toEqual({})
15
+ expect(router.routes.OPTIONS).toEqual({})
16
+ })
17
+
18
+ test('routes, bad syntax', async () => {
19
+ const galbe = new Galbe()
20
+
21
+ const invalidPaths = [
22
+ '.',
23
+ '/@',
24
+ '/-ta',
25
+ '/test/-ta',
26
+ '/test/my.path',
27
+ '/hell@/w0rld',
28
+ '/last-',
29
+ '../',
30
+ './x',
31
+ '/hello?'
32
+ ]
33
+
34
+ for (const p of invalidPaths) {
35
+ try {
36
+ galbe.get(p, () => {})
37
+ expect.unreachable()
38
+ } catch (err) {
39
+ expect(err).toBeInstanceOf(SyntaxError)
40
+ }
41
+ }
42
+ })
43
+
44
+ test('chaining routes', async () => {
45
+ const galbe = new Galbe()
46
+ const router = galbe.router
47
+
48
+ expect(router.routes.GET).toEqual({})
49
+
50
+ galbe.get('/', () => {})
51
+ let r: RouteNode | undefined = router.routes.GET
52
+
53
+ expect(r?.route?.method).toBe('get')
54
+ expect(r?.route?.path).toBe('/')
55
+ expect(r?.param).toBeUndefined()
56
+ expect(r?.children).toBeUndefined()
57
+
58
+ const mockHandler = () => {}
59
+ galbe.get('/test', mockHandler)
60
+ r = r?.children?.test
61
+
62
+ expect(r?.route?.method).toBe('get')
63
+ expect(r?.route?.path).toBe('/test')
64
+ expect(r?.param).toBeUndefined()
65
+ expect(r?.children).toBeUndefined()
66
+ expect(r?.route?.handler).toBe(mockHandler)
67
+
68
+ const mockHandler2 = () => {}
69
+ galbe.get('/test/:foo', mockHandler2)
70
+ r = r?.param
71
+
72
+ expect(r?.route?.method).toBe('get')
73
+ expect(r?.route?.path).toBe('/test/:foo')
74
+ expect(r?.param).toBeUndefined()
75
+ expect(r?.children).toBeUndefined()
76
+ expect(r?.route?.handler).toBe(mockHandler2)
77
+
78
+ const mockHandler3 = () => {}
79
+ galbe.get('/test/:foo/bar', mockHandler3)
80
+
81
+ expect(r?.children).toHaveProperty('bar')
82
+ expect(r?.param).toBeUndefined()
83
+ expect(r?.children?.bar?.route?.handler).toBe(mockHandler3)
84
+ })
85
+
86
+ test('redefining root', async () => {
87
+ const galbe = new Galbe()
88
+ const router = galbe.router
89
+
90
+ const [h1, h2, h3] = [() => {}, () => {}, () => {}]
91
+
92
+ galbe.get('/', h1)
93
+ galbe.get('/test', h2)
94
+ galbe.get('/', h3)
95
+
96
+ let r: RouteNode | undefined = router.routes.GET
97
+ expect(router.prefix).toBe('')
98
+ expect(r?.route?.method).toBe('get')
99
+ expect(r?.route?.path).toBe('/')
100
+ expect(r?.route?.handler).toBe(h3)
101
+ expect(r?.children?.test?.route?.method).toBe('get')
102
+ expect(r?.children?.test?.route?.handler).toBe(h2)
103
+
104
+ const [h4, h5] = [() => {}, () => {}]
105
+
106
+ galbe.get('/foo/bar', h4)
107
+ galbe.get('/foo', h5)
108
+
109
+ r = router?.routes?.GET?.children?.foo
110
+ expect(r?.route?.method).toBe('get')
111
+ expect(r?.route?.path).toBe('/foo')
112
+ expect(r?.route?.handler).toBe(h5)
113
+ expect(r?.children?.bar?.route?.method).toBe('get')
114
+ expect(r?.children?.bar?.route?.path).toBe('/foo/bar')
115
+ expect(r?.children?.bar?.route?.handler).toBe(h4)
116
+ })
117
+
118
+ test('find route', async () => {
119
+ const galbe = new Galbe()
120
+ const router = galbe.router
121
+
122
+ const [h1, h3, h4] = [() => {}, () => {}, () => {}, () => {}]
123
+
124
+ galbe.get('/', h1)
125
+ galbe.get('/test/foo', h3)
126
+ galbe.get('/test/foo/bar', h4)
127
+
128
+ let r1 = router.find('GET', '/')
129
+ expect(r1.path).toBe('/')
130
+ expect(r1.handler).toBe(h1)
131
+ let r2 = router.find('GET', '/test/foo')
132
+ expect(r2.path).toBe('/test/foo')
133
+ expect(r2.handler).toBe(h3)
134
+ let r3 = router.find('GET', '/test/foo/bar')
135
+ expect(r3.path).toBe('/test/foo/bar')
136
+ expect(r3.handler).toBe(h4)
137
+
138
+ try {
139
+ router.find('GET', '/test')
140
+ expect.unreachable()
141
+ } catch (err: any) {
142
+ expect(err).toBeInstanceOf(NotFoundError)
143
+ expect(err.status).toBe(404)
144
+ expect(err.error).toBe('Not found')
145
+ }
146
+
147
+ try {
148
+ router.find('GET', '/test/bar/bar')
149
+ expect.unreachable()
150
+ } catch (err: any) {
151
+ expect(err).toBeInstanceOf(NotFoundError)
152
+ expect(err.status).toBe(404)
153
+ expect(err.error).toBe('Not found')
154
+ }
155
+ })
156
+
157
+ test('find route param', async () => {
158
+ const galbe = new Galbe()
159
+ const router = galbe.router
160
+
161
+ const [h1, h2] = [() => {}, () => {}]
162
+
163
+ galbe.get('/test/:foo', h1)
164
+ galbe.get('/test/test', h2)
165
+
166
+ let r1 = router.find('GET', '/test/42')
167
+ expect(r1.path).toBe('/test/:foo')
168
+ expect(r1.handler).toBe(h1)
169
+
170
+ let r2 = router.find('GET', '/test/test')
171
+ expect(r2.path).toBe('/test/test')
172
+ expect(r2.handler).toBe(h2)
173
+ })
174
+
175
+ test('wildcard routes', async () => {
176
+ const galbe = new Galbe()
177
+ const router = galbe.router
178
+
179
+ const [h1, h2, h3, h4] = [() => {}, () => {}, () => {}, () => {}]
180
+
181
+ galbe.get('/test/foo/*', h1)
182
+ galbe.get('/test/foo/bar', h2)
183
+ galbe.get('/test/foo/:p/bar', h3)
184
+
185
+ galbe.get('/test/foo/*/lol', h4)
186
+
187
+ let r1 = router.find('GET', '/test/foo/bar/42')
188
+ expect(r1.path).toBe('/test/foo/*')
189
+ expect(r1.handler).toBe(h1)
190
+
191
+ let r2 = router.find('GET', '/test//foo/bar')
192
+ expect(r2.path).toBe('/test/foo/bar')
193
+ expect(r2.handler).toBe(h2)
194
+
195
+ let r3 = router.find('GET', '/test/foo/bar/bar')
196
+ expect(r3.path).toBe('/test/foo/:p/bar')
197
+ expect(r3.handler).toBe(h3)
198
+
199
+ let r4 = router.find('GET', '/test/foo/foo')
200
+ expect(r4.path).toBe('/test/foo/*')
201
+ expect(r4.handler).toBe(h1)
202
+
203
+ let r5 = router.find('GET', '/test/foo/toto/lol')
204
+ expect(r5.path).toBe('/test/foo/*/lol')
205
+ expect(r5.handler).toBe(h4)
206
+ })
207
+ })
@@ -0,0 +1,102 @@
1
+ import { T, type Context } from '../src'
2
+
3
+ export type Case = {
4
+ body?: any
5
+ type?: string
6
+ schema: string
7
+ expected: { status: number; type?: string; resp?: any }
8
+ }
9
+
10
+ export const formdata = (data: Record<string, string | string[] | Blob>): FormData => {
11
+ const form = new FormData()
12
+ for (const [k, v] of Object.entries(data)) {
13
+ if (Array.isArray(v)) for (const v2 of v) form.append(k, v2)
14
+ else form.append(k, v)
15
+ }
16
+ return form
17
+ }
18
+ export const fileHash = async (ba: any) =>
19
+ Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', ba)))
20
+ .map(byte => byte.toString(16).padStart(2, '0'))
21
+ .join('')
22
+ export const decoder = new TextDecoder()
23
+
24
+ export const schema_objectBase = {
25
+ ba: T.ByteArray(),
26
+ string: T.String(),
27
+ number: T.Number(),
28
+ bool: T.Boolean(),
29
+ any: T.Any(),
30
+ optional: T.Optional(T.Any())
31
+ }
32
+ export const schema_object = {
33
+ ...schema_objectBase,
34
+ object: T.Object(T.Any()),
35
+ array: T.Array(T.Any())
36
+ }
37
+
38
+ export const isAsyncIterator = (obj: any) => {
39
+ if (Object(obj) !== obj) return false
40
+ const method = obj[Symbol.asyncIterator]
41
+ if (typeof method != 'function') return false
42
+ const aIter = method.call(obj)
43
+ return aIter === obj
44
+ }
45
+
46
+ const parseAsyncIterator = async (body: any): Promise<any> => {
47
+ const chunks = []
48
+ let type
49
+ for await (const chunk of body) {
50
+ if (chunk instanceof Uint8Array) type = 'ByteArray'
51
+ else if (typeof chunk === 'string') type = 'string'
52
+ chunks.push(chunk)
53
+ }
54
+ // @ts-ignore
55
+ if (type === 'ByteArray') return new Uint8Array(chunks.map(c => Array.from(c)).flat())
56
+ if (type === 'string') return chunks.join('')
57
+ return chunks
58
+ }
59
+ const parseBody = async (body: any): Promise<any> =>
60
+ typeof body === 'object' && body !== null && !Array.isArray(body)
61
+ ? Object.fromEntries(
62
+ await Promise.all(
63
+ Object.entries(body).map(async ([k, v]) => {
64
+ if (v instanceof Uint8Array) return [k, await fileHash(v)]
65
+ else return [k, await parseBody(v)]
66
+ })
67
+ )
68
+ )
69
+ : body
70
+ export const handleBody = async (ctx: any) => {
71
+ if (ctx.body === undefined) {
72
+ return { type: 'undefined' }
73
+ }
74
+ if (ctx?.body instanceof ReadableStream) {
75
+ let content = ''
76
+ for await (const chunk of ctx.body) {
77
+ if (typeof chunk === 'string') content += chunk
78
+ else content += decoder.decode(chunk)
79
+ }
80
+ return { type: 'ReadableStream', content }
81
+ }
82
+ if (Array.isArray(ctx.body)) {
83
+ return { type: 'array', content: ctx.body }
84
+ }
85
+ if (ctx?.body instanceof Uint8Array) {
86
+ return { type: 'ByteArray', content: decoder.decode(ctx.body) }
87
+ }
88
+ if (isAsyncIterator(ctx.body)) {
89
+ return { type: 'AsyncIterator', content: await parseAsyncIterator(ctx.body) }
90
+ } else {
91
+ return { type: typeof ctx.body, content: await parseBody(ctx.body) }
92
+ }
93
+ }
94
+ export const handleUrlFormStream = async (ctx: Context) => {
95
+ let resp: Record<string, any> = {}
96
+ for await (const [k, v] of ctx.body) {
97
+ if (k in resp) {
98
+ resp[k] = Array.isArray(resp[k]) ? [...resp[k], v] : [resp[k], v]
99
+ } else resp[k] = v
100
+ }
101
+ return { type: 'object', content: resp }
102
+ }