From 8e018243991498633ec226ec2edc53d42e1fe1b3 Mon Sep 17 00:00:00 2001 From: sor4chi Date: Thu, 30 Nov 2023 21:53:36 +0900 Subject: [PATCH 01/11] feat: implement type utility --- packages/hono-do/src/utils.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 packages/hono-do/src/utils.ts diff --git a/packages/hono-do/src/utils.ts b/packages/hono-do/src/utils.ts new file mode 100644 index 0000000..f90eb12 --- /dev/null +++ b/packages/hono-do/src/utils.ts @@ -0,0 +1,6 @@ +export type MergeArray = [ + ...T, + ...U, +] extends [...infer R] + ? R + : never; From cb35289fc9af10f02632af955163587f0550a93b Mon Sep 17 00:00:00 2001 From: sor4chi Date: Thu, 30 Nov 2023 21:54:12 +0900 Subject: [PATCH 02/11] feat: implement hono do's type and refactor --- packages/hono-do/src/types.ts | 68 +++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 packages/hono-do/src/types.ts diff --git a/packages/hono-do/src/types.ts b/packages/hono-do/src/types.ts new file mode 100644 index 0000000..98a9832 --- /dev/null +++ b/packages/hono-do/src/types.ts @@ -0,0 +1,68 @@ +import { Env, Hono, Schema } from "hono"; + +import { MergeArray } from "./utils"; + +export interface HonoObjectVars {} + +interface HonoObjectState< + E extends Env = Env, + S extends Schema = Record, + BasePath extends string = "/", +> { + app: Hono; + state: DurableObjectState; + vars: HonoObjectVars; +} + +export interface HonoObject< + E extends Env = Env, + S extends Schema = Record, + BasePath extends string = "/", +> extends HonoObjectState { + (this: HonoObject, state: DurableObjectState): void; + alarm: (handler: AlarmHandler) => HonoObject; + webSocketMessage: ( + handler: WebSocketMessageHandler, + ) => HonoObject; + webSocketClose: ( + handler: WebSocketCloseHandler, + ) => HonoObject; + webSocketError: ( + handler: WebSocketErrorHandler, + ) => HonoObject; +} + +export type AlarmHandler = ( + ...args: MergeArray< + Parameters>, + [state: DurableObjectState, vars: HonoObjectVars] + > +) => ReturnType>; + +export type WebSocketMessageHandler = ( + ...args: MergeArray< + Parameters>, + [state: DurableObjectState, vars: HonoObjectVars] + > +) => ReturnType>; + +export type WebSocketCloseHandler = ( + ...args: MergeArray< + Parameters>, + [state: DurableObjectState, vars: HonoObjectVars] + > +) => ReturnType>; + +export type WebSocketErrorHandler = ( + ...args: MergeArray< + Parameters>, + [state: DurableObjectState, vars: HonoObjectVars] + > +) => ReturnType>; + +export interface HonoObjectHandlers { + alarm?: AlarmHandler; + webSocketMessage?: WebSocketMessageHandler; + webSocketClose?: WebSocketCloseHandler; + webSocketError?: WebSocketErrorHandler; +} From 2aef19b0addcd5fd8bd1d7ccd42c061ae5908b3b Mon Sep 17 00:00:00 2001 From: sor4chi Date: Thu, 30 Nov 2023 21:54:27 +0900 Subject: [PATCH 03/11] feat: create custom error map for DX --- packages/hono-do/src/error.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 packages/hono-do/src/error.ts diff --git a/packages/hono-do/src/error.ts b/packages/hono-do/src/error.ts new file mode 100644 index 0000000..a16c900 --- /dev/null +++ b/packages/hono-do/src/error.ts @@ -0,0 +1,11 @@ +export class HonoDOError extends Error { + constructor(message: string) { + super(message); + this.name = "HonoDOError"; + } +} + +export const Errors = { + handlerAlreadySet: (handlerName: string) => + new HonoDOError(`Handler ${handlerName} already set`), +}; From e7944ee62b294ff838049643ef8628ad0f442455 Mon Sep 17 00:00:00 2001 From: sor4chi Date: Thu, 30 Nov 2023 21:56:31 +0900 Subject: [PATCH 04/11] feat: add some DurableObject's custom field handler --- packages/hono-do/src/index.ts | 127 ++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 44 deletions(-) diff --git a/packages/hono-do/src/index.ts b/packages/hono-do/src/index.ts index 279cae2..3e2e43b 100644 --- a/packages/hono-do/src/index.ts +++ b/packages/hono-do/src/index.ts @@ -1,16 +1,15 @@ import { Env, Hono, Schema } from "hono"; -export interface HonoObjectVars {} - -interface HonoObject< - E extends Env = Env, - S extends Schema = Record, - BasePath extends string = "/", -> { - app: Hono; - state: DurableObjectState; - vars: HonoObjectVars; -} +import { HonoDOError, Errors } from "./error"; +import { + AlarmHandler, + WebSocketMessageHandler, + WebSocketCloseHandler, + WebSocketErrorHandler, + HonoObjectHandlers, + HonoObjectVars, + HonoObject, +} from "./types"; export function generateHonoObject< E extends Env = Env, @@ -23,52 +22,92 @@ export function generateHonoObject< state: DurableObjectState, vars: HonoObjectVars, ) => void | Promise, - alarm?: ( - state: DurableObjectState, - vars: HonoObjectVars, - ) => void | Promise, + handlers: HonoObjectHandlers = {}, ) { - const honoObject = function ( - this: HonoObject, - state: DurableObjectState, - ) { - const app = new Hono().basePath(basePath); + const _handlers: HonoObjectHandlers = { + ...handlers, + }; + + const app = new Hono().basePath(basePath); + + const honoObject = function (this, state) { this.app = app; this.state = state; this.vars = {}; state.blockConcurrencyWhile(async () => { await cb(app, state, this.vars); }); - }; + } as HonoObject; honoObject.prototype.fetch = function ( this: HonoObject, - request: Request, + ...args: Parameters> + ) { + return this.app.fetch(...args); + }; + + honoObject.prototype.alarm = async function ( + this: HonoObject, + ) { + await _handlers.alarm?.(this.state, this.vars); + }; + + honoObject.prototype.webSocketMessage = function ( + this: HonoObject, + ...args: Parameters> + ) { + _handlers.webSocketMessage?.(...args, this.state, this.vars); + }; + + honoObject.prototype.webSocketClose = function ( + this: HonoObject, + ...args: Parameters> + ) { + _handlers.webSocketClose?.(...args, this.state, this.vars); + }; + + honoObject.prototype.webSocketError = function ( + this: HonoObject, + ...args: Parameters> ) { - return this.app.fetch(request); + _handlers.webSocketError?.(...args, this.state, this.vars); + }; + + const isCalledMap = new Map(); + + honoObject.alarm = function (handler: AlarmHandler) { + const name = "alarm"; + if (isCalledMap.get(name)) throw Errors.handlerAlreadySet(name); + _handlers.alarm = handler; + isCalledMap.set(name, true); + return honoObject; + }; + + honoObject.webSocketMessage = function (handler: WebSocketMessageHandler) { + const name = "webSocketMessage"; + if (isCalledMap.get(name)) throw Errors.handlerAlreadySet(name); + _handlers.webSocketMessage = handler; + isCalledMap.set(name, true); + return honoObject; }; - if (alarm !== undefined) { - honoObject.prototype.alarm = async function ( - this: HonoObject, - ) { - await alarm(this.state, this.vars); - }; - } else { - honoObject.alarm = function ( - cb: ( - state: DurableObjectState, - vars: HonoObjectVars, - ) => void | Promise, - ) { - honoObject.prototype.alarm = async function ( - this: HonoObject, - ) { - await cb(this.state, this.vars); - }; - return honoObject; - }; - } + honoObject.webSocketClose = function (handler: WebSocketCloseHandler) { + const name = "webSocketClose"; + if (isCalledMap.get(name)) throw Errors.handlerAlreadySet(name); + _handlers.webSocketClose = handler; + isCalledMap.set(name, true); + return honoObject; + }; + + honoObject.webSocketError = function (handler: WebSocketErrorHandler) { + const name = "webSocketError"; + if (isCalledMap.get(name)) throw Errors.handlerAlreadySet(name); + _handlers.webSocketError = handler; + isCalledMap.set(name, true); + return honoObject; + }; return honoObject; } + +export { HonoObjectVars, HonoDOError }; From bce634f1e8780d3bec43cc4ee084bcaa70b4f679 Mon Sep 17 00:00:00 2001 From: sor4chi Date: Thu, 30 Nov 2023 22:53:55 +0900 Subject: [PATCH 05/11] test: add some case about MergeArray --- packages/hono-do/tests/utils.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/hono-do/tests/utils.test.ts diff --git a/packages/hono-do/tests/utils.test.ts b/packages/hono-do/tests/utils.test.ts new file mode 100644 index 0000000..12c70ba --- /dev/null +++ b/packages/hono-do/tests/utils.test.ts @@ -0,0 +1,18 @@ +import { MergeArray } from "../src/utils"; + +describe("MergeArray", () => { + it("should work", () => { + expectTypeOf>().toEqualTypeOf<[1, 2, 3, 4]>(); + }); + + it("should work with empty array", () => { + expectTypeOf>().toEqualTypeOf<[3, 4]>(); + expectTypeOf>().toEqualTypeOf<[1, 2]>(); + }); + + it("should work with tuple", () => { + expectTypeOf>().toEqualTypeOf< + [a: 1, b: 2, c: 3, d: 4] + >(); + }); +}); From 2a1ef24e7353673b193d98da5ce1b993d1ffef45 Mon Sep 17 00:00:00 2001 From: sor4chi Date: Thu, 30 Nov 2023 23:00:59 +0900 Subject: [PATCH 06/11] test: add a case about alarm handler --- .../hono-do/tests/fixtures/alarm/index.ts | 43 +++++++++++++++++++ .../tests/fixtures/alarm/wrangler.toml | 9 ++++ packages/hono-do/tests/index.test.ts | 27 ++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 packages/hono-do/tests/fixtures/alarm/index.ts create mode 100644 packages/hono-do/tests/fixtures/alarm/wrangler.toml diff --git a/packages/hono-do/tests/fixtures/alarm/index.ts b/packages/hono-do/tests/fixtures/alarm/index.ts new file mode 100644 index 0000000..5562c44 --- /dev/null +++ b/packages/hono-do/tests/fixtures/alarm/index.ts @@ -0,0 +1,43 @@ +import { Hono } from "hono"; + +import { generateHonoObject } from "../../../src"; + +declare module "../../../src" { + interface HonoObjectVars { + message: string; + } +} + +const app = new Hono<{ + Bindings: { + ALARM: DurableObjectNamespace; + }; +}>(); + +app.all("/alarm/*", (c) => { + const id = c.env.ALARM.idFromName("alarm"); + const obj = c.env.ALARM.get(id); + return obj.fetch(c.req.raw); +}); + +export const Alarm = generateHonoObject( + "/alarm", + async (app, { storage }, vars) => { + app.post("/", async (c) => { + const currentAlarm = await storage.getAlarm(); + if (currentAlarm == null) { + await storage.setAlarm(Date.now() + 100); + } + + return c.json({ queued: true }); + }); + + app.get("/", async (c) => { + return c.text(vars.message); + }); + }, +).alarm(async (state, vars) => { + vars.message = "Hello, Hono DO!"; +}); + +export default app; diff --git a/packages/hono-do/tests/fixtures/alarm/wrangler.toml b/packages/hono-do/tests/fixtures/alarm/wrangler.toml new file mode 100644 index 0000000..516c87e --- /dev/null +++ b/packages/hono-do/tests/fixtures/alarm/wrangler.toml @@ -0,0 +1,9 @@ +name = "hono-do-test" +compatibility_date = "2023-01-01" + +[durable_objects] +bindings = [{ name = "ALARM", class_name = "Alarm" }] + +[[migrations]] +tag = "v1" +new_classes = ["Alarm"] diff --git a/packages/hono-do/tests/index.test.ts b/packages/hono-do/tests/index.test.ts index 038c9a1..cad7e50 100644 --- a/packages/hono-do/tests/index.test.ts +++ b/packages/hono-do/tests/index.test.ts @@ -72,4 +72,31 @@ describe("Worker", () => { expect(await resp.text()).toBe("1"); }); }); + + describe("Alarm", () => { + let worker: UnstableDevWorker; + + beforeEach(async () => { + worker = await unstable_dev(join(__dirname, "fixtures/alarm/index.ts"), { + experimental: { disableExperimentalWarning: true }, + }); + }); + + afterEach(async () => { + await worker.stop(); + }); + + it("should work alarm handler", async () => { + const resp = await worker.fetch("/alarm", { method: "POST" }); + expect(resp.status).toBe(200); + expect(await resp.json()).toStrictEqual({ queued: true }); + + // alarm set "Hello, Hono DO!" to vars.message after 100ms + await new Promise((resolve) => setTimeout(resolve, 300)); + + const resp2 = await worker.fetch("/alarm"); + expect(resp2.status).toBe(200); + expect(await resp2.text()).toBe("Hello, Hono DO!"); + }); + }); }); From b89ceabf2548baf0e9c699d065cbda1b50b3842b Mon Sep 17 00:00:00 2001 From: sor4chi Date: Thu, 30 Nov 2023 23:03:51 +0900 Subject: [PATCH 07/11] test: add a case about generateHonoObject's handler interfaces --- packages/hono-do/tests/index.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/hono-do/tests/index.test.ts b/packages/hono-do/tests/index.test.ts index cad7e50..7e97e91 100644 --- a/packages/hono-do/tests/index.test.ts +++ b/packages/hono-do/tests/index.test.ts @@ -27,6 +27,22 @@ describe("generateHonoObject", () => { }); }); }); + + it("should work with handler chain by flat way", async () => { + const DO = generateHonoObject("/", () => {}); + DO.alarm(async () => {}); + DO.webSocketMessage(async () => {}); + DO.webSocketClose(async () => {}); + DO.webSocketError(async () => {}); + }); + + it("should work with handler chain by chain way", async () => { + generateHonoObject("/", () => {}) + .alarm(async () => {}) + .webSocketMessage(async () => {}) + .webSocketClose(async () => {}) + .webSocketError(async () => {}); + }); }); describe("Worker", () => { From dad6ec3b028f7f3f2e017886c92711f724d143ed Mon Sep 17 00:00:00 2001 From: sor4chi Date: Thu, 30 Nov 2023 23:07:37 +0900 Subject: [PATCH 08/11] test: add a case of checking basepath type --- packages/hono-do/tests/index.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/hono-do/tests/index.test.ts b/packages/hono-do/tests/index.test.ts index 7e97e91..3b027a2 100644 --- a/packages/hono-do/tests/index.test.ts +++ b/packages/hono-do/tests/index.test.ts @@ -28,6 +28,15 @@ describe("generateHonoObject", () => { }); }); + it("should work with type-safe base path", async () => { + generateHonoObject("/:hogeId", (app) => { + app.get("/:fugaId", async (c) => { + expectTypeOf(c.req.param("hogeId")).toEqualTypeOf(); + expectTypeOf(c.req.param("hogeId")).toEqualTypeOf(); + }); + }); + }); + it("should work with handler chain by flat way", async () => { const DO = generateHonoObject("/", () => {}); DO.alarm(async () => {}); From f659d6ce48e0c77f785a813faf1585d8f0b216ec Mon Sep 17 00:00:00 2001 From: sor4chi Date: Thu, 30 Nov 2023 23:22:34 +0900 Subject: [PATCH 09/11] chore: changeset --- .changeset/three-actors-develop.md | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .changeset/three-actors-develop.md diff --git a/.changeset/three-actors-develop.md b/.changeset/three-actors-develop.md new file mode 100644 index 0000000..e0c95f7 --- /dev/null +++ b/.changeset/three-actors-develop.md @@ -0,0 +1,36 @@ +--- +"hono-do": minor +--- + +Support for three handlers about [Hibernation Websocket API](https://developers.cloudflare.com/durable-objects/learning/websockets/#websocket-hibernation). + +- `webSocketMessage` handler +- `webSocketClose` handler +- `webSocketError` handler + +You can use these handlers same way as `alarm` handler in Hono DO. + +## Usage + +### Flat way + +```ts +const DO = generateHonoObject("/", () => {}); +DO.alarm(async () => {}); +DO.webSocketMessage(async () => {}); +DO.webSocketClose(async () => {}); +DO.webSocketError(async () => {}); +``` + +### Chaining way + +```ts +generateHonoObject("/", () => {}) + .alarm(async () => {}) + .webSocketMessage(async () => {}) + .webSocketClose(async () => {}) + .webSocketError(async () => {}); +``` + +Take care for registering multiple handlers for same event. +If you register so, you will get an error. From 47d525f23bccb99fc073f910834170c7e945f682 Mon Sep 17 00:00:00 2001 From: sor4chi Date: Thu, 30 Nov 2023 23:28:55 +0900 Subject: [PATCH 10/11] chore: add some type-ignore comments to fixture to avoid global declare --- .../hono-do/tests/fixtures/alarm/index.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/hono-do/tests/fixtures/alarm/index.ts b/packages/hono-do/tests/fixtures/alarm/index.ts index 5562c44..fcb4b31 100644 --- a/packages/hono-do/tests/fixtures/alarm/index.ts +++ b/packages/hono-do/tests/fixtures/alarm/index.ts @@ -2,12 +2,6 @@ import { Hono } from "hono"; import { generateHonoObject } from "../../../src"; -declare module "../../../src" { - interface HonoObjectVars { - message: string; - } -} - const app = new Hono<{ Bindings: { ALARM: DurableObjectNamespace; @@ -33,10 +27,24 @@ export const Alarm = generateHonoObject( }); app.get("/", async (c) => { + // NOTE: + // ```ts + // declare module "hono-do" { + // interface HonoObjectVars { + // message: string; + // } + // } + // ``` + // + // This will resolve the type error, but this code effects other files. so I don't use this. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error return c.text(vars.message); }); }, ).alarm(async (state, vars) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error vars.message = "Hello, Hono DO!"; }); From 883699131cbb80320ab8538aa07dbac08a4c3bac Mon Sep 17 00:00:00 2001 From: sor4chi Date: Thu, 30 Nov 2023 23:35:59 +0900 Subject: [PATCH 11/11] test: add a case about duplicate handler registration --- packages/hono-do/tests/index.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/hono-do/tests/index.test.ts b/packages/hono-do/tests/index.test.ts index 3b027a2..54a6ff9 100644 --- a/packages/hono-do/tests/index.test.ts +++ b/packages/hono-do/tests/index.test.ts @@ -3,6 +3,7 @@ import { join } from "path"; import { unstable_dev } from "wrangler"; import { generateHonoObject } from "../src"; +import { Errors } from "../src/error"; import type { UnstableDevWorker } from "wrangler"; @@ -52,6 +53,17 @@ describe("generateHonoObject", () => { .webSocketClose(async () => {}) .webSocketError(async () => {}); }); + + it("should error when multiple handler set to same Hono Object", async () => { + const DO = generateHonoObject("/", () => {}); + DO.webSocketMessage(async () => {}); + expect(() => DO.alarm(async () => {})).not.toThrowError( + Errors.handlerAlreadySet("alarm"), + ); + expect(() => DO.webSocketMessage(async () => {})).toThrowError( + Errors.handlerAlreadySet("webSocketMessage"), + ); + }); }); describe("Worker", () => {