diff --git a/benchmarks/routers/package.json b/benchmarks/routers/package.json index ee55583a0..9f02aac22 100644 --- a/benchmarks/routers/package.json +++ b/benchmarks/routers/package.json @@ -15,6 +15,7 @@ "find-my-way": "^7.4.0", "koa-router": "^12.0.0", "koa-tree-router": "^0.12.1", + "memoirist": "^0.1.4", "mitata": "^0.1.6", "radix3": "^1.0.1", "trek-router": "^1.2.0" diff --git a/benchmarks/routers/src/bench.mts b/benchmarks/routers/src/bench.mts index 5cc10ccfc..c0cf69301 100644 --- a/benchmarks/routers/src/bench.mts +++ b/benchmarks/routers/src/bench.mts @@ -5,6 +5,7 @@ import { regExpRouter, trieRouter } from './hono.mts' import { koaRouter } from './koa-router.mts' import { koaTreeRouter } from './koa-tree-router.mts' import { medleyRouter } from './medley-router.mts' +import { memoiristRouter } from './memoirist.mts' import { radix3Router } from './radix3.mts' import type { Route, RouterInterface } from './tool.mts' import { trekRouter } from './trek-router.mts' @@ -19,6 +20,7 @@ const routers: RouterInterface[] = [ expressRouter, koaRouter, radix3Router, + memoiristRouter, ] medleyRouter.match({ method: 'GET', path: '/user' }) diff --git a/benchmarks/routers/src/hono.mts b/benchmarks/routers/src/hono.mts index da4049b08..6c72cad4f 100644 --- a/benchmarks/routers/src/hono.mts +++ b/benchmarks/routers/src/hono.mts @@ -1,6 +1,6 @@ -import type { Router } from '../../../src/router.ts' import { RegExpRouter } from '../../../src/router/reg-exp-router/index.ts' import { TrieRouter } from '../../../src/router/trie-router/index.ts' +import type { Router } from '../../../src/router.ts' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' diff --git a/benchmarks/routers/src/memoirist.mts b/benchmarks/routers/src/memoirist.mts new file mode 100644 index 000000000..45ce143d1 --- /dev/null +++ b/benchmarks/routers/src/memoirist.mts @@ -0,0 +1,17 @@ +import { Memoirist } from 'memoirist' +import type { RouterInterface } from './tool.mts' +import { routes, handler } from './tool.mts' + +const name = 'Memoirist' +const router = new Memoirist() + +for (const route of routes) { + router.add(route.method, route.path, handler) +} + +export const memoiristRouter: RouterInterface = { + name, + match: (route) => { + router.find(route.method, route.path) + }, +} diff --git a/deno_dist/client/client.ts b/deno_dist/client/client.ts index 37f670da9..78ea4150d 100644 --- a/deno_dist/client/client.ts +++ b/deno_dist/client/client.ts @@ -1,5 +1,6 @@ import type { Hono } from '../hono.ts' import type { ValidationTargets } from '../types.ts' +import { serialize } from '../utils/cookie.ts' import type { UnionToIntersection } from '../utils/types.ts' import type { Callback, Client, ClientRequestOptions } from './types.ts' import { deepMerge, mergePath, removeIndexString, replaceUrlParam } from './utils.ts' @@ -86,7 +87,19 @@ class ClientRequestImpl { let methodUpperCase = this.method.toUpperCase() let setBody = !(methodUpperCase === 'GET' || methodUpperCase === 'HEAD') - const headerValues: Record = opt?.headers ? opt.headers : {} + const headerValues: Record = { + ...(args?.header ?? {}), + ...(opt?.headers ? opt.headers : {}), + } + + if (args?.cookie) { + const cookies: string[] = [] + for (const [key, value] of Object.entries(args.cookie)) { + cookies.push(serialize(key, value, { path: '/' })) + } + headerValues['Cookie'] = cookies.join(',') + } + if (this.cType) headerValues['Content-Type'] = this.cType const headers = new Headers(headerValues ?? undefined) diff --git a/deno_dist/client/types.ts b/deno_dist/client/types.ts index d5e25a262..c41493809 100644 --- a/deno_dist/client/types.ts +++ b/deno_dist/client/types.ts @@ -4,20 +4,21 @@ import type { RemoveBlankRecord } from '../utils/types.ts' type HonoRequest = typeof Hono.prototype['request'] -export type ClientRequestOptions = { - headers?: Record - fetch?: typeof fetch | HonoRequest -} +export type ClientRequestOptions = keyof T extends never + ? { + headers?: Record + fetch?: typeof fetch | HonoRequest + } + : { + headers: T + fetch?: typeof fetch | HonoRequest + } type ClientRequest = { [M in keyof S]: S[M] extends { input: infer R; output: infer O } ? RemoveBlankRecord extends never ? (args?: {}, options?: ClientRequestOptions) => Promise> - : ( - // Client does not support `header` and `cookie` - args: Omit, - options?: ClientRequestOptions - ) => Promise> + : (args: R, options?: ClientRequestOptions) => Promise> : never } & { $url: () => URL @@ -53,12 +54,26 @@ export type Fetch = ( export type InferResponseType = T extends ( // eslint-disable-next-line @typescript-eslint/no-explicit-any - args: any | undefined + args: any | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options: any | undefined ) => Promise> ? O : never -export type InferRequestType = T extends (args: infer R) => Promise> +export type InferRequestType = T extends ( + args: infer R, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options: any | undefined +) => Promise> + ? NonNullable + : never + +export type InferRequestOptionsType = T extends ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: any, + options: infer R +) => Promise> ? NonNullable : never diff --git a/deno_dist/context.ts b/deno_dist/context.ts index b09a8826f..efc4d3d13 100644 --- a/deno_dist/context.ts +++ b/deno_dist/context.ts @@ -4,6 +4,7 @@ import type { Env, NotFoundHandler, Input, TypedResponse } from './types.ts' import type { CookieOptions } from './utils/cookie.ts' import { serialize } from './utils/cookie.ts' import type { StatusCode } from './utils/http-status.ts' +import { StreamingApi } from './utils/stream.ts' import type { JSONValue, InterfaceToType } from './utils/types.ts' type Runtime = 'node' | 'deno' | 'bun' | 'workerd' | 'fastly' | 'edge-light' | 'lagon' | 'other' @@ -85,6 +86,8 @@ type ContextOptions = { notFoundHandler?: NotFoundHandler } +const TEXT_PLAIN = 'text/plain; charset=UTF-8' + export class Context< // eslint-disable-next-line @typescript-eslint/no-explicit-any E extends Env = any, @@ -310,7 +313,7 @@ export class Context< // If Content-Type is not set, we don't have to set `text/plain`. // Fewer the header values, it will be faster. if (this._pH['content-type']) { - this._pH['content-type'] = 'text/plain; charset=UTF-8' + this._pH['content-type'] = TEXT_PLAIN } return typeof arg === 'number' ? this.newResponse(text, arg, headers) @@ -371,6 +374,32 @@ export class Context< return this.newResponse(null, status) } + streamText = ( + cb: (stream: StreamingApi) => Promise, + arg?: StatusCode | ResponseInit, + headers?: HeaderRecord + ): Response => { + headers ??= {} + this.header('content-type', TEXT_PLAIN) + this.header('x-content-type-options', 'nosniff') + this.header('transfer-encoding', 'chunked') + return this.stream(cb, arg, headers) + } + + stream = ( + cb: (stream: StreamingApi) => Promise, + arg?: StatusCode | ResponseInit, + headers?: HeaderRecord + ): Response => { + const { readable, writable } = new TransformStream() + const stream = new StreamingApi(writable) + cb(stream).finally(() => stream.close()) + + return typeof arg === 'number' + ? this.newResponse(readable, arg, headers) + : this.newResponse(readable, arg) + } + /** @deprecated * Use Cookie Middleware instead of `c.cookie()`. The `c.cookie()` will be removed in v4. * diff --git a/deno_dist/helper/testing/index.ts b/deno_dist/helper/testing/index.ts new file mode 100644 index 000000000..f152609d3 --- /dev/null +++ b/deno_dist/helper/testing/index.ts @@ -0,0 +1,18 @@ +import { hc } from '../../client/index.ts' +import type { Hono } from '../../hono.ts' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ExtractEnv = T extends Hono ? E : never + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const testClient = >( + app: T, + Env?: ExtractEnv['Bindings'] | {}, + executionCtx?: ExecutionContext +) => { + const customFetch = (input: RequestInfo | URL, init?: RequestInit) => { + return app.request(input, init, Env, executionCtx) + } + + return hc('', { fetch: customFetch }) +} diff --git a/deno_dist/middleware/jwt/index.ts b/deno_dist/middleware/jwt/index.ts index aed288fc1..44c9fb796 100644 --- a/deno_dist/middleware/jwt/index.ts +++ b/deno_dist/middleware/jwt/index.ts @@ -77,3 +77,7 @@ export const jwt = (options: { await next() } } + +export const verify = Jwt.verify +export const decode = Jwt.decode +export const sign = Jwt.sign diff --git a/deno_dist/utils/body.ts b/deno_dist/utils/body.ts index 8d533aa63..a06cf7ab5 100644 --- a/deno_dist/utils/body.ts +++ b/deno_dist/utils/body.ts @@ -1,6 +1,6 @@ import type { HonoRequest } from '../request.ts' -export type BodyData = Record +export type BodyData = Record export const parseBody = async ( request: HonoRequest | Request @@ -17,7 +17,17 @@ export const parseBody = async ( if (formData) { const form: BodyData = {} formData.forEach((value, key) => { - form[key] = value + if (key.slice(-2) === '[]') { + if (!form[key]) { + form[key] = [value.toString()] + } else { + if (Array.isArray(form[key])) { + ;(form[key] as string[]).push(value.toString()) + } + } + } else { + form[key] = value + } }) body = form } diff --git a/deno_dist/utils/cookie.ts b/deno_dist/utils/cookie.ts index e5092ca9c..476c60404 100644 --- a/deno_dist/utils/cookie.ts +++ b/deno_dist/utils/cookie.ts @@ -11,6 +11,7 @@ export type CookieOptions = { secure?: boolean signingSecret?: string sameSite?: 'Strict' | 'Lax' | 'None' + partitioned?: boolean } const algorithm = { name: 'HMAC', hash: 'SHA-256' } @@ -104,15 +105,15 @@ const _serialize = (name: string, value: string, opt: CookieOptions = {}): strin } if (opt.domain) { - cookie += '; Domain=' + opt.domain + cookie += `; Domain=${opt.domain}` } if (opt.path) { - cookie += '; Path=' + opt.path + cookie += `; Path=${opt.path}` } if (opt.expires) { - cookie += '; Expires=' + opt.expires.toUTCString() + cookie += `; Expires=${opt.expires.toUTCString()}` } if (opt.httpOnly) { @@ -127,6 +128,10 @@ const _serialize = (name: string, value: string, opt: CookieOptions = {}): strin cookie += `; SameSite=${opt.sameSite}` } + if (opt.partitioned) { + cookie += '; Partitioned' + } + return cookie } diff --git a/deno_dist/utils/stream.ts b/deno_dist/utils/stream.ts new file mode 100644 index 000000000..1fcda3223 --- /dev/null +++ b/deno_dist/utils/stream.ts @@ -0,0 +1,46 @@ +export class StreamingApi { + private writer: WritableStreamDefaultWriter + private encoder: TextEncoder + private writable: WritableStream + + constructor(writable: WritableStream) { + this.writable = writable + this.writer = writable.getWriter() + this.encoder = new TextEncoder() + } + + async write(input: Uint8Array | string) { + try { + if (typeof input === 'string') { + input = this.encoder.encode(input) + } + await this.writer.write(input) + } catch (e) { + // Do nothing. If you want to handle errors, create a stream by yourself. + } + return this + } + + async writeln(input: string) { + await this.write(input + '\n') + return this + } + + sleep(ms: number) { + return new Promise((res) => setTimeout(res, ms)) + } + + async close() { + try { + await this.writer.close() + } catch (e) { + // Do nothing. If you want to handle errors, create a stream by yourself. + } + } + + async pipe(body: ReadableStream) { + this.writer.releaseLock() + await body.pipeTo(this.writable, { preventClose: true }) + this.writer = this.writable.getWriter() + } +} diff --git a/package.json b/package.json index 7d8496d32..223bacfcd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hono", - "version": "3.6.3", + "version": "3.7.0-rc.1", "description": "Ultrafast web framework for the Edges", "main": "dist/cjs/index.js", "type": "module", @@ -233,6 +233,11 @@ "types": "./dist/types/adapter/lambda-edge/index.d.ts", "import": "./dist/adapter/lambda-edge/index.js", "require": "./dist/cjs/adapter/lambda-edge/index.js" + }, + "./testing": { + "types": "./dist/types/helper/testing/index.d.ts", + "import": "./dist/helper/testing/index.js", + "require": "./dist/cjs/helper/testing/index.js" } }, "typesVersions": { @@ -353,6 +358,9 @@ ], "lambda-edge": [ "./dist/types/adapter/lambda-edge" + ], + "testing": [ + "./dist/types/helper/testing" ] } }, diff --git a/src/client/client.test.ts b/src/client/client.test.ts index 0880cbb08..a0f624436 100644 --- a/src/client/client.test.ts +++ b/src/client/client.test.ts @@ -7,10 +7,11 @@ import { setupServer } from 'msw/node' import _fetch, { Request as NodeFetchRequest } from 'node-fetch' import { vi } from 'vitest' import { Hono } from '../hono' +import { parse, serialize } from '../utils/cookie' import type { Equal, Expect } from '../utils/types' import { validator } from '../validator' import { hc } from './client' -import type { InferRequestType, InferResponseType } from './types' +import type { InferRequestOptionsType, InferRequestType, InferResponseType } from './types' // @ts-ignore global.fetch = _fetch @@ -25,16 +26,14 @@ describe('Basic - JSON', () => { const route = app .post( '/posts', - // Client does not support `cookie` validator('cookie', () => { return {} as { debug: string } }), - // Client does not support `header` validator('header', () => { return {} as { - 'x-request-id': string + 'x-message': string } }), validator('json', () => { @@ -97,12 +96,14 @@ describe('Basic - JSON', () => { const res = await client.posts.$post( { json: payload, - }, - { - headers: { + header: { 'x-message': 'foobar', }, - } + cookie: { + debug: 'true', + }, + }, + {} ) expect(res.ok).toBe(true) @@ -121,7 +122,7 @@ describe('Basic - JSON', () => { }) }) -describe('Basic - query, queries, form, and path params', () => { +describe('Basic - query, queries, form, path params, header and cookie', () => { const app = new Hono() const route = app @@ -162,6 +163,30 @@ describe('Basic - query, queries, form, and path params', () => { return c.jsonT(data) } ) + .get( + '/header', + validator('header', () => { + return { + 'x-message-id': 'Hello', + } + }), + (c) => { + const data = c.req.valid('header') + return c.jsonT(data) + } + ) + .get( + '/cookie', + validator('cookie', () => { + return { + hello: 'world', + } + }), + (c) => { + const data = c.req.valid('cookie') + return c.jsonT(data) + } + ) const server = setupServer( rest.get('http://localhost/api/search', (req, res, ctx) => { @@ -193,6 +218,16 @@ describe('Basic - query, queries, form, and path params', () => { // @ts-ignore const string = String.fromCharCode.apply('', new Uint8Array(buffer)) return res(ctx.status(200), ctx.text(string)) + }), + rest.get('http://localhost/api/header', async (req, res, ctx) => { + const message = await req.headers.get('x-message-id') + return res(ctx.status(200), ctx.json({ 'x-message-id': message })) + }), + + rest.get('http://localhost/api/cookie', async (req, res, ctx) => { + const obj = parse(req.headers.get('cookie') || '') + const value = obj['hello'] + return res(ctx.status(200), ctx.json({ hello: value })) }) ) @@ -248,6 +283,30 @@ describe('Basic - query, queries, form, and path params', () => { expect(res.status).toBe(200) expect(await res.text()).toMatch('Good Night') }) + + it('Should get 200 response - header', async () => { + const header = { + 'x-message-id': 'Hello', + } + const res = await client.header.$get({ + header, + }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual(header) + }) + + it('Should get 200 response - cookie', async () => { + const cookie = { + hello: 'world', + } + const res = await client.cookie.$get({ + cookie, + }) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual(cookie) + }) }) describe('Infer the response/request type', () => { @@ -260,6 +319,16 @@ describe('Infer the response/request type', () => { age: 'dummy', } }), + validator('header', () => { + return { + 'x-request-id': 'dummy', + } + }), + validator('cookie', () => { + return { + name: 'dummy', + } + }), (c) => c.jsonT({ id: 123, @@ -293,6 +362,30 @@ describe('Infer the response/request type', () => { type verify = Expect> }) + it('Should infer request header type the type correctly', () => { + const client = hc('/') + const req = client.index.$get + type c = typeof req + + type Actual = InferRequestType + type Expected = { + 'x-request-id': string + } + type verify = Expect> + }) + + it('Should infer request cookie type the type correctly', () => { + const client = hc('/') + const req = client.index.$get + type c = typeof req + + type Actual = InferRequestType + type Expected = { + name: string + } + type verify = Expect> + }) + describe('Without input', () => { const route = app.get('/', (c) => c.jsonT({ ok: true })) type AppType = typeof route diff --git a/src/client/client.ts b/src/client/client.ts index 7111f4154..5543d3ae3 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -1,5 +1,6 @@ import type { Hono } from '../hono' import type { ValidationTargets } from '../types' +import { serialize } from '../utils/cookie' import type { UnionToIntersection } from '../utils/types' import type { Callback, Client, ClientRequestOptions } from './types' import { deepMerge, mergePath, removeIndexString, replaceUrlParam } from './utils' @@ -86,7 +87,19 @@ class ClientRequestImpl { let methodUpperCase = this.method.toUpperCase() let setBody = !(methodUpperCase === 'GET' || methodUpperCase === 'HEAD') - const headerValues: Record = opt?.headers ? opt.headers : {} + const headerValues: Record = { + ...(args?.header ?? {}), + ...(opt?.headers ? opt.headers : {}), + } + + if (args?.cookie) { + const cookies: string[] = [] + for (const [key, value] of Object.entries(args.cookie)) { + cookies.push(serialize(key, value, { path: '/' })) + } + headerValues['Cookie'] = cookies.join(',') + } + if (this.cType) headerValues['Content-Type'] = this.cType const headers = new Headers(headerValues ?? undefined) diff --git a/src/client/types.ts b/src/client/types.ts index 83f4e3061..6a8be26fb 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -4,20 +4,21 @@ import type { RemoveBlankRecord } from '../utils/types' type HonoRequest = typeof Hono.prototype['request'] -export type ClientRequestOptions = { - headers?: Record - fetch?: typeof fetch | HonoRequest -} +export type ClientRequestOptions = keyof T extends never + ? { + headers?: Record + fetch?: typeof fetch | HonoRequest + } + : { + headers: T + fetch?: typeof fetch | HonoRequest + } type ClientRequest = { [M in keyof S]: S[M] extends { input: infer R; output: infer O } ? RemoveBlankRecord extends never ? (args?: {}, options?: ClientRequestOptions) => Promise> - : ( - // Client does not support `header` and `cookie` - args: Omit, - options?: ClientRequestOptions - ) => Promise> + : (args: R, options?: ClientRequestOptions) => Promise> : never } & { $url: () => URL @@ -53,12 +54,26 @@ export type Fetch = ( export type InferResponseType = T extends ( // eslint-disable-next-line @typescript-eslint/no-explicit-any - args: any | undefined + args: any | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options: any | undefined ) => Promise> ? O : never -export type InferRequestType = T extends (args: infer R) => Promise> +export type InferRequestType = T extends ( + args: infer R, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options: any | undefined +) => Promise> + ? NonNullable + : never + +export type InferRequestOptionsType = T extends ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args: any, + options: infer R +) => Promise> ? NonNullable : never diff --git a/src/context.test.ts b/src/context.test.ts index dd5e0a84c..bb5a63138 100644 --- a/src/context.test.ts +++ b/src/context.test.ts @@ -261,6 +261,47 @@ describe('Pass a ResponseInit to respond methods', () => { expect(res.headers.get('content-type')).toMatch(/^text\/html/) expect(await res.text()).toBe('

foo

') }) + + it('c.streamText()', async () => { + const res = c.streamText(async (stream) => { + for (let i = 0; i < 3; i++) { + await stream.write(`${i}`) + await stream.sleep(1) + } + }) + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toMatch(/^text\/plain/) + expect(res.headers.get('x-content-type-options')).toBe('nosniff') + expect(res.headers.get('transfer-encoding')).toBe('chunked') + + if (!res.body) { + throw new Error('Body is null') + } + const reader = res.body.getReader() + const decoder = new TextDecoder() + for (let i = 0; i < 3; i++) { + const { value } = await reader.read() + expect(decoder.decode(value)).toEqual(`${i}`) + } + }) + + it('c.stream()', async () => { + const res = c.stream(async (stream) => { + for (let i = 0; i < 3; i++) { + await stream.write(new Uint8Array([i])) + await stream.sleep(1) + } + }) + if (!res.body) { + throw new Error('Body is null') + } + const reader = res.body.getReader() + for (let i = 0; i < 3; i++) { + const { value } = await reader.read() + expect(value).toEqual(new Uint8Array([i])) + } + }) }) declare module './context' { diff --git a/src/context.ts b/src/context.ts index f1efa78fa..e2338a663 100644 --- a/src/context.ts +++ b/src/context.ts @@ -4,6 +4,7 @@ import type { Env, NotFoundHandler, Input, TypedResponse } from './types' import type { CookieOptions } from './utils/cookie' import { serialize } from './utils/cookie' import type { StatusCode } from './utils/http-status' +import { StreamingApi } from './utils/stream' import type { JSONValue, InterfaceToType } from './utils/types' type Runtime = 'node' | 'deno' | 'bun' | 'workerd' | 'fastly' | 'edge-light' | 'lagon' | 'other' @@ -85,6 +86,8 @@ type ContextOptions = { notFoundHandler?: NotFoundHandler } +const TEXT_PLAIN = 'text/plain; charset=UTF-8' + export class Context< // eslint-disable-next-line @typescript-eslint/no-explicit-any E extends Env = any, @@ -310,7 +313,7 @@ export class Context< // If Content-Type is not set, we don't have to set `text/plain`. // Fewer the header values, it will be faster. if (this._pH['content-type']) { - this._pH['content-type'] = 'text/plain; charset=UTF-8' + this._pH['content-type'] = TEXT_PLAIN } return typeof arg === 'number' ? this.newResponse(text, arg, headers) @@ -371,6 +374,32 @@ export class Context< return this.newResponse(null, status) } + streamText = ( + cb: (stream: StreamingApi) => Promise, + arg?: StatusCode | ResponseInit, + headers?: HeaderRecord + ): Response => { + headers ??= {} + this.header('content-type', TEXT_PLAIN) + this.header('x-content-type-options', 'nosniff') + this.header('transfer-encoding', 'chunked') + return this.stream(cb, arg, headers) + } + + stream = ( + cb: (stream: StreamingApi) => Promise, + arg?: StatusCode | ResponseInit, + headers?: HeaderRecord + ): Response => { + const { readable, writable } = new TransformStream() + const stream = new StreamingApi(writable) + cb(stream).finally(() => stream.close()) + + return typeof arg === 'number' + ? this.newResponse(readable, arg, headers) + : this.newResponse(readable, arg) + } + /** @deprecated * Use Cookie Middleware instead of `c.cookie()`. The `c.cookie()` will be removed in v4. * diff --git a/src/helper/cookie/index.test.ts b/src/helper/cookie/index.test.ts index 037853add..3517da54b 100644 --- a/src/helper/cookie/index.test.ts +++ b/src/helper/cookie/index.test.ts @@ -244,7 +244,7 @@ describe('Cookie Middleware', () => { return c.text('Give cookie') }) - it('Delete multile cookies', async () => { + it('Delete multiple cookies', async () => { const res2 = await app.request('http://localhost/delete-cookie-multiple') expect(res2.status).toBe(200) const header2 = res2.headers.get('Set-Cookie') diff --git a/src/helper/testing/index.test.ts b/src/helper/testing/index.test.ts new file mode 100644 index 000000000..6e74f469a --- /dev/null +++ b/src/helper/testing/index.test.ts @@ -0,0 +1,19 @@ +import { Hono } from '../../hono' +import { testClient } from '.' + +describe('hono testClinet', () => { + it('should return the correct search result', async () => { + const app = new Hono().get('/search', (c) => c.jsonT({ hello: 'world' })) + const res = await testClient(app).search.$get() + expect(await res.json()).toEqual({ hello: 'world' }) + }) + + it('should return the correct environment variables value', async () => { + type Bindings = { hello: string } + const app = new Hono<{ Bindings: Bindings }>().get('/search', (c) => { + return c.jsonT({ hello: c.env.hello }) + }) + const res = await testClient(app, { hello: 'world' }).search.$get() + expect(await res.json()).toEqual({ hello: 'world' }) + }) +}) diff --git a/src/helper/testing/index.ts b/src/helper/testing/index.ts new file mode 100644 index 000000000..f0457cb5b --- /dev/null +++ b/src/helper/testing/index.ts @@ -0,0 +1,18 @@ +import { hc } from '../../client' +import type { Hono } from '../../hono' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ExtractEnv = T extends Hono ? E : never + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const testClient = >( + app: T, + Env?: ExtractEnv['Bindings'] | {}, + executionCtx?: ExecutionContext +) => { + const customFetch = (input: RequestInfo | URL, init?: RequestInit) => { + return app.request(input, init, Env, executionCtx) + } + + return hc('', { fetch: customFetch }) +} diff --git a/src/middleware/jwt/index.ts b/src/middleware/jwt/index.ts index 90819d969..9ec1b5faa 100644 --- a/src/middleware/jwt/index.ts +++ b/src/middleware/jwt/index.ts @@ -77,3 +77,7 @@ export const jwt = (options: { await next() } } + +export const verify = Jwt.verify +export const decode = Jwt.decode +export const sign = Jwt.sign diff --git a/src/utils/body.test.ts b/src/utils/body.test.ts index a56fa114f..a47174ac1 100644 --- a/src/utils/body.test.ts +++ b/src/utils/body.test.ts @@ -4,12 +4,14 @@ describe('Parse Body Util', () => { it('should parse `multipart/form-data`', async () => { const data = new FormData() data.append('message', 'hello') + data.append('multi[]', 'foo') + data.append('multi[]', 'bar') const req = new Request('https://localhost/form', { method: 'POST', body: data, // `Content-Type` header must not be set. }) - expect(await parseBody(req)).toEqual({ message: 'hello' }) + expect(await parseBody(req)).toEqual({ message: 'hello', 'multi[]': ['foo', 'bar'] }) }) it('should parse `x-www-form-urlencoded`', async () => { diff --git a/src/utils/body.ts b/src/utils/body.ts index 4211aef3f..91ac5e921 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -1,6 +1,6 @@ import type { HonoRequest } from '../request' -export type BodyData = Record +export type BodyData = Record export const parseBody = async ( request: HonoRequest | Request @@ -17,7 +17,17 @@ export const parseBody = async ( if (formData) { const form: BodyData = {} formData.forEach((value, key) => { - form[key] = value + if (key.slice(-2) === '[]') { + if (!form[key]) { + form[key] = [value.toString()] + } else { + if (Array.isArray(form[key])) { + ;(form[key] as string[]).push(value.toString()) + } + } + } else { + form[key] = value + } }) body = form } diff --git a/src/utils/cookie.test.ts b/src/utils/cookie.test.ts index 856f221eb..4b6b4b8e9 100644 --- a/src/utils/cookie.test.ts +++ b/src/utils/cookie.test.ts @@ -162,9 +162,10 @@ describe('Set cookie', () => { maxAge: 1000, expires: new Date(Date.UTC(2000, 11, 24, 10, 30, 59, 900)), sameSite: 'Strict', + partitioned: true, }) expect(serialized).toBe( - 'great_cookie=banana; Max-Age=1000; Domain=example.com; Path=/; Expires=Sun, 24 Dec 2000 10:30:59 GMT; HttpOnly; Secure; SameSite=Strict' + 'great_cookie=banana; Max-Age=1000; Domain=example.com; Path=/; Expires=Sun, 24 Dec 2000 10:30:59 GMT; HttpOnly; Secure; SameSite=Strict; Partitioned' ) }) @@ -186,9 +187,10 @@ describe('Set cookie', () => { maxAge: 1000, expires: new Date(Date.UTC(2000, 11, 24, 10, 30, 59, 900)), sameSite: 'Strict', + partitioned: true, }) expect(serialized).toBe( - 'great_cookie=banana.hSo6gB7YT2db0WBiEAakEmh7dtwEL0DSp76G23WvHuQ%3D; Max-Age=1000; Domain=example.com; Path=/; Expires=Sun, 24 Dec 2000 10:30:59 GMT; HttpOnly; Secure; SameSite=Strict' + 'great_cookie=banana.hSo6gB7YT2db0WBiEAakEmh7dtwEL0DSp76G23WvHuQ%3D; Max-Age=1000; Domain=example.com; Path=/; Expires=Sun, 24 Dec 2000 10:30:59 GMT; HttpOnly; Secure; SameSite=Strict; Partitioned' ) }) diff --git a/src/utils/cookie.ts b/src/utils/cookie.ts index 8a2dda057..8456a8dd9 100644 --- a/src/utils/cookie.ts +++ b/src/utils/cookie.ts @@ -11,6 +11,7 @@ export type CookieOptions = { secure?: boolean signingSecret?: string sameSite?: 'Strict' | 'Lax' | 'None' + partitioned?: boolean } const algorithm = { name: 'HMAC', hash: 'SHA-256' } @@ -104,15 +105,15 @@ const _serialize = (name: string, value: string, opt: CookieOptions = {}): strin } if (opt.domain) { - cookie += '; Domain=' + opt.domain + cookie += `; Domain=${opt.domain}` } if (opt.path) { - cookie += '; Path=' + opt.path + cookie += `; Path=${opt.path}` } if (opt.expires) { - cookie += '; Expires=' + opt.expires.toUTCString() + cookie += `; Expires=${opt.expires.toUTCString()}` } if (opt.httpOnly) { @@ -127,6 +128,10 @@ const _serialize = (name: string, value: string, opt: CookieOptions = {}): strin cookie += `; SameSite=${opt.sameSite}` } + if (opt.partitioned) { + cookie += '; Partitioned' + } + return cookie } diff --git a/src/utils/stream.test.ts b/src/utils/stream.test.ts new file mode 100644 index 000000000..0eefe7668 --- /dev/null +++ b/src/utils/stream.test.ts @@ -0,0 +1,85 @@ +import { StreamingApi } from './stream' + +describe('StreamingApi', () => { + it('write(string)', async () => { + const { readable, writable } = new TransformStream() + const api = new StreamingApi(writable) + const reader = readable.getReader() + api.write('foo') + expect((await reader.read()).value).toEqual(new TextEncoder().encode('foo')) + api.write('bar') + expect((await reader.read()).value).toEqual(new TextEncoder().encode('bar')) + }) + + it('write(Uint8Array)', async () => { + const { readable, writable } = new TransformStream() + const api = new StreamingApi(writable) + const reader = readable.getReader() + api.write(new Uint8Array([1, 2, 3])) + expect((await reader.read()).value).toEqual(new Uint8Array([1, 2, 3])) + api.write(new Uint8Array([4, 5, 6])) + expect((await reader.read()).value).toEqual(new Uint8Array([4, 5, 6])) + }) + + it('writeln(string)', async () => { + const { readable, writable } = new TransformStream() + const api = new StreamingApi(writable) + const reader = readable.getReader() + api.writeln('foo') + expect((await reader.read()).value).toEqual(new TextEncoder().encode('foo\n')) + api.writeln('bar') + expect((await reader.read()).value).toEqual(new TextEncoder().encode('bar\n')) + }) + + it('pipe()', async () => { + const { readable: senderReadable, writable: senderWritable } = new TransformStream() + + // send data to readable in other scope + ;(async () => { + const writer = senderWritable.getWriter() + await writer.write(new TextEncoder().encode('foo')) + await writer.write(new TextEncoder().encode('bar')) + // await writer.close() + })() + + const { readable: receiverReadable, writable: receiverWritable } = new TransformStream() + + const api = new StreamingApi(receiverWritable) + + // pipe readable to api in other scope + ;(async () => { + await api.pipe(senderReadable) + })() + + // read data from api + const reader = receiverReadable.getReader() + expect((await reader.read()).value).toEqual(new TextEncoder().encode('foo')) + expect((await reader.read()).value).toEqual(new TextEncoder().encode('bar')) + }) + + it('close()', async () => { + const { readable, writable } = new TransformStream() + const api = new StreamingApi(writable) + const reader = readable.getReader() + await api.close() + expect((await reader.read()).done).toBe(true) + }) + + it('should not throw an error in write()', async () => { + const { writable } = new TransformStream() + const api = new StreamingApi(writable) + await api.close() + const write = () => api.write('foo') + expect(write).not.toThrow() + }) + + it('should not throw an error in close()', async () => { + const { writable } = new TransformStream() + const api = new StreamingApi(writable) + const close = async () => { + await api.close() + await api.close() + } + expect(close).not.toThrow() + }) +}) diff --git a/src/utils/stream.ts b/src/utils/stream.ts new file mode 100644 index 000000000..1fcda3223 --- /dev/null +++ b/src/utils/stream.ts @@ -0,0 +1,46 @@ +export class StreamingApi { + private writer: WritableStreamDefaultWriter + private encoder: TextEncoder + private writable: WritableStream + + constructor(writable: WritableStream) { + this.writable = writable + this.writer = writable.getWriter() + this.encoder = new TextEncoder() + } + + async write(input: Uint8Array | string) { + try { + if (typeof input === 'string') { + input = this.encoder.encode(input) + } + await this.writer.write(input) + } catch (e) { + // Do nothing. If you want to handle errors, create a stream by yourself. + } + return this + } + + async writeln(input: string) { + await this.write(input + '\n') + return this + } + + sleep(ms: number) { + return new Promise((res) => setTimeout(res, ms)) + } + + async close() { + try { + await this.writer.close() + } catch (e) { + // Do nothing. If you want to handle errors, create a stream by yourself. + } + } + + async pipe(body: ReadableStream) { + this.writer.releaseLock() + await body.pipeTo(this.writable, { preventClose: true }) + this.writer = this.writable.getWriter() + } +}