{"id":15715308,"url":"https://github.com/cloudflare/dog","last_synced_at":"2025-04-09T05:10:29.032Z","repository":{"id":37739295,"uuid":"399366320","full_name":"cloudflare/dog","owner":"cloudflare","description":"Durable Object Groups","archived":false,"fork":false,"pushed_at":"2024-09-27T15:13:29.000Z","size":88,"stargazers_count":285,"open_issues_count":8,"forks_count":10,"subscribers_count":13,"default_branch":"master","last_synced_at":"2025-04-02T04:03:45.694Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/cloudflare.png","metadata":{"files":{"readme":"readme.md","changelog":null,"contributing":null,"funding":null,"license":"license","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2021-08-24T06:57:33.000Z","updated_at":"2025-03-26T01:19:39.000Z","dependencies_parsed_at":"2024-06-21T20:18:46.065Z","dependency_job_id":"3d399f1f-c71b-479c-9d19-0e404c223dc5","html_url":"https://github.com/cloudflare/dog","commit_stats":{"total_commits":75,"total_committers":3,"mean_commits":25.0,"dds":"0.053333333333333344","last_synced_commit":"b174d23551633c6ded67bb17a51ad671509029af"},"previous_names":[],"tags_count":8,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cloudflare%2Fdog","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cloudflare%2Fdog/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cloudflare%2Fdog/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cloudflare%2Fdog/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/cloudflare","download_url":"https://codeload.github.com/cloudflare/dog/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":247980837,"owners_count":21027808,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-10-03T21:40:54.253Z","updated_at":"2025-04-09T05:10:29.025Z","avatar_url":"https://github.com/cloudflare.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"🚧 This project is not suitable for production usage 🚧\n\n# DOG [![CI](https://github.com/lukeed/dog/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/lukeed/dog/actions/workflows/ci.yml)\n\n\u003e Durable Object Groups\n\n## Features\n\n* Supports `Replica` workloads using the HTTP and/or WS protocols\n* Creates or reuses a `Replica` based on configured connection limit\n* Includes `Replica`-to-`Replica` (peer-to-peer) communication\n* Ready for strongly-typed, strict TypeScript usage\n* Allows an active connection to:\n    * `broadcast` messages to the entire cluster\n    * `emit` messages to `Replica`-owned connections\n    * send a `whisper` a single connection within the cluster\n\n## Overview\n\nWith DOG, it's easy to setup named clusters of related [Durable Objects](https://developers.cloudflare.com/workers/runtime-apis/durable-objects). Each cluster is controlled by a [`Group`](#group), which directs an incoming `Request` to a specific [`Replica`](#replica) instance. A `Group` adheres to the user-defined [limit](#group) of active connections per `Replica` and, in doing so, will reuse existing or create new `Replica` instances as necessary.\n\nDOG includes convenience methods that allow a `Replica` to directly communicate with another `Replica` belonging to the same `Group` – effectively a peer-to-peer/gossip network. Additionally, when dealing with active client connections, a `Replica` class allows you to:\n\n* `broadcast` a message to all active connections within the _entire_ cluster\n* `emit` a message only to active connections owned by the `Replica` itself\n* `whisper` a message to a single, targeted connection (via your own identification system); even if it's owned by another `Replica` instance!\n\n`Group` and `Replica` are both [abstract classes](https://www.typescriptlang.org/docs/handbook/classes.html#abstract-classes), which means that you're allowed — **and required** — to extend them with your own application needs. You may define your own class methods, add your own state properties, or use [Durable Storage](https://developers.cloudflare.com/workers/runtime-apis/durable-objects#transactional-storage-api) to fulfill your needs.\n\nPlease see [Usage](#usage), the [API](#api) docs, and the [example application](/example/worker) for further information!\n\n\n## Install\n\n```sh\n$ npm install dog\n```\n\n\n## Usage\n\n\u003e Refer to the [`/example`](/example) for a complete Chat Room application.\n\n```ts\nimport { identify, Group, Replica } from 'dog';\n\n// deployed as `POOL` binding\nexport class Pool extends Group {\n  limit = 50; // each Replica handles 50 connections max\n\n  link(env: Bindings) {\n    return {\n      child: env.TASK, // receiving Replica\n      self: env.POOL, // self-identifier\n    };\n  }\n}\n\n// deployed as `TASK` binding\nexport class Task extends Replica {\n  link(env) {\n    return {\n      parent: env.POOL, // parent Group\n      self: env.TASK, // self-identifier\n    };\n  }\n\n  async onmessage(socket, data) {\n    let message = JSON.parse(data);\n    console.log('[task] onmessage', message);\n\n    if (message.type === 'crawl:url') {\n      let { url } = message;\n      // ...\n      let output = { url, done: true };\n      // alert everyone that the task is complete\n      return socket.broadcast(JSON.stringify(output), true);\n    }\n\n    // other events\n  }\n\n  receive(req) {\n    // Receive \u0026 handle the request\n    // NOTE: This is the original, forwarded request\n    let { pathname } = new URL(req.url);\n\n    // Rely on internal util for WebSocket upgrade\n    if (pathname === '/ws') return this.connect(req);\n\n    // Any other custom routing behavior(s)\n    if (pathname === '/') return new Response('OK');\n\n    return toError('Unknown path', 404);\n  }\n}\n\nfunction toError(msg, status) {\n  return new Response(msg, { status });\n}\n\n// Module Worker\nexport default {\n  fetch(req, env, ctx) {\n    // Accept: /tasks/\u003ctaskname\u003e\n    let match = /[/]tasks[/]([^/]+)[/]?/.exec(req.url);\n    if (match == null) return toError('Missing task name', 404);\n\n    let taskname = match[1].trim();\n    if (taskname.length \u003c 1) return toError('Invalid task name', 400);\n\n    // Generate Durable Object ID from taskname\n    let group = env.POOL.idFromName(taskname);\n\n    // Custom request identifier logic\n    let reqid = req.headers.get('x-request-id');\n\n    // Identify the `Replica` stub to use\n    let replica = await identify(group, reqid, {\n      parent: env.POOL,\n      child: env.TASK,\n    });\n\n    // (Optional) Save reqid -\u003e replica.id\n    // await KV.put(`req::${reqid}`, replica.id.toString());\n\n    // Send request to the Replica instance\n    return replica.fetch(req);\n  }\n}\n```\n\n## API\n\n### `identify`\n\n\u003e **Note:** Refer to the [TypeScript definitions](/index.d.ts#L149) for more information.\n\nThe utility function to identify a `Replica` to be used and, if necessary, will create a new `Replica` if none are available. Returns the `Replica` stub directly.\n\n\n### `Group`\n\n\u003e **Note:** Refer to the [TypeScript definitions](/index.d.ts#L116) for more information.\n\n***Required:***\n\n* `limit: number` – the maximum number of active connections a `Replica` can handle\n* `link(env: Bindings): { self, child }` – define the relationships between this `Group` and its `Replica` child class\n\nA `Group` is initial coordinator for the cluster. It receives a user-supplied request identifier, `ReqID`, and replies with the Durable Object ID for the `Replica` instance to be used. If the `ReqID` has been seen before, the Group will attempt to target the same Replica that the `ReqID` was previously connected to. If the `ReqID` is unknown, the Group will send the request to the least-utilized `Replica` instance or generate a new `Replica` ID to be used.\n\nWhen targeting an existing `Replica` instance, the Group verifies that the `Replica` actually has availability for the request, as determined by the user-supplied `limit` value. If a new Replica instance needs to be created, the Group's `clusterize()` method is called to generate a new `Replica` instance identifier. You may override this method with your own logic – for example, including a [jurisdiction](https://developers.cloudflare.com/workers/runtime-apis/durable-objects#restricting-objects-to-a-jurisdiction) – but by default, the Group calls `newUniqueId()` for a system-guaranteed identifier.\n\nThe number of active connections within each `Replica` instance is automatically tracked and shared between the `Replica` and its `Group` parent. The `Replica`'s count is decremented when the connection is closed. This means that when a `Replica` works with WebSockets, open connections continue to reserve `Replica` quota until closed. Non-upgraded HTTP connections close and decrement the `Replica` count as soon as a `Response` is returned.\n\n\u003e **Important:** *Do not* define your own `fetch()` method! \u003cbr\u003eDoing so requires that `super.fetch()` be called appropriately, otherwise the entire cluster's inter-communication will fail.\n\nYou may attach any additional state and/or methods to your `Group` class extension.\n\n\n### `Replica`\n\n\u003e **Note:** Refer to the [TypeScript definitions](/index.d.ts#60) for more information.\n\n***Required:***\n\n* `link(env: Bindings): { self, child }` – define the relationships between this `Replica` and its `Group` parent class\n* `receive(req: Request): Promise\u003cResponse\u003e | Response` – a user-supplied method to handle an incoming Request\n\nA `Replica` is the cluster's terminating node. In other words, it's your workhorse and is where the bulk of your application logic will reside. By default, a `Replica` actually _does nothing_ and requires your user-supplied code to become useful. It does, however, provide you with utilities, lifecycle hooks, and event listeners to organize and structure your logic.\n\nA `Replica` can only receive a `Request` from its parent `Group` or from its `Replica` siblings/peers. Because of this, you **cannot** define a `fetch()` method in your `Replica` class extension, otherwise all internal routing and inter-communication will break.\n\nHowever, this _does not_ mean that you cannot deploy your own external-facing routing solution!\n\nIf an incoming request to a `Replica` is not an internal DOG event, the request is passed to your `receive` method, which receives the original `Request` without any modifications. This means that the execution order for a client request looks like this:\n\n\u003c!-- TODO: actual graphic --\u003e\n```\nclient request\n└──\u003e dog.identify(...)\n      │   ├──\u003e Group#fetch (internal)\n      │   └──\u003e Group#clusterize (optional)\n      └──\u003e Replica\n          └──\u003e Replica.fetch (user)\n              └──\u003e Replica#receive\n```\n\nYour `receive` method is the final handler and decides what the `Replica` actually does.\n\nIf you'd like to remain in the HTTP protocol, then you can treat `receive()` as if it were the underyling `fetch()` method. Otherwise, to upgrade the HTTP connection into a WebSocket connection, then you may reach for the `Replica.connect()` method, which handles the upgrade and unlocks the rest of the `Replica` abstractions.\n\nInternally, a [`Socket` interface](/index.d.ts#23) is instantiated and passed to WebSocket event listeners that you chose to define. For example, to handle incoming messages or to react to a new connection, your `Replica` class may including the following:\n\n```js\nimport { Replica } from 'dog';\n\nexport class Counter extends Replica {\n  #counts = new Map\u003cstring, number\u003e;\n\n  onopen(socket) {\n    // via dog.identify\n    // ~\u003e your own ReqID\n    let reqid = socket.uid;\n    this.#counts.set(reqid, 0);\n\n    // notify others ONLY in Replica\n    socket.emit(`\"${reqid}\" has joined`);\n  }\n\n  onmessage(socket, data) {\n    let reqid = socket.uid;\n    let current = this.#counts.get(reqid);\n\n    // data is always a string\n    let msg = JSON.parse(data);\n\n    if (msg.type === '+1') count++;\n    else if (msg.type === '-1') count--;\n    else return; // unknown msg type\n\n    this.#counts.set(reqid, count);\n\n    // tell EVERYONE in cluster about new count\n    socket.broadcast(`\"${reqid}\" now has ${count}`);\n  }\n\n  receive(req) {\n    // Only accept \"/ws\" pathname\n    let isWS = /^[/]ws[/]?/.test(req.url);\n    // Handle upgrade, return 101 status\n    if (isWS) return this.connect(req);\n    // Otherwise return a 404\n    return new Response('Invalid', { status: 404 });\n  }\n}\n```\n\nThe `Replica` class allows you to optionally define event listeners for the underlying WebSocket events. Whether or not you define `onclose` and/or `onerror` listeners, the `Replica` will always notify the `Group` parent when the WebSocket connection is closed. The event listeners may be asynchronous and their names follow the browser's [`WebSocket` event names](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket#events):\n\n* `onopen` – the `Replica` established a  `WebSocket` connection\n* `onmessage` – the `Replica` received a message from the `WebSocket` connection\n* `onerror` – the `WebSocket` connection terminated due to an error\n* `onclose` – the `WebSocket` connection was closed\n\n\u003e **Note:** If defined, the `onclose` listener will be called in the absence of an `onerror` listener.\n\nFinally, a `Replica` may communicate directly with its `Replica` peers in the cluster. This does not rely on WebSockets nor does it require you to use them! It can, however, be leveraged at any point during your HTTP and/or WebSocket handlers.\n\nIn DOG, this peer-to-peer communication is called gossip – because `Replica`s are typically talking _about_ their connections but without _involving_ the connections; AKA, behind their backs!\n\nIn order for a `Replica` to hear gossip, it must define an `ongossip` method handler. It will receive a decoded JSON object and must return a new JSON object so that DOG can serialize it and deliver it to sender. In practice, this internal communication is happening over HTTP which means that each `Gossip.Message` must represent point-in-time information.\n\nReturning to the `Counter` example, suppose the `Counter` objects needs to coordinate with one another to determine a leaderboard. Refreshing this leaderboard could be done through a new `refresh:leaderboard` message, for example:\n\n```js\nimport { Replica } from 'dog';\n\nexport class Counter extends Replica {\n  #counts = new Map\u003cstring, number\u003e;\n  #lastupdate = 0; // timestamp\n  #leaders = []; // Array\u003cstring, number\u003e[]\n\n  // NOTE: now `async` method\n  async onmessage(socket, data) {\n    let reqid = socket.uid;\n    let current = this.#counts.get(reqid);\n\n    // data is always a string\n    let msg = JSON.parse(data);\n\n    // ...\n\n    if (msg.type === 'refresh:leaderboard') {\n      // Only gossip if cache is older than 60s\n      if (Date.now() - this.#lastupdate \u003e 60e3) {\n        // `ongossip` returns Array\u003c[string,number][]\u003e\n        let results = await this.gossip({ type: 'ask:scores' });\n        let leaders = results.flat(1); // [ [reqid,count], [reqid,count], ... ]\n\n        // sort by highest scores, keep top 10 only\n        this.#scores = leaders.sort((a, b) =\u003e b[1] - a[1]).slice(0, 10);\n        this.#lastupdate = Date.now();\n      }\n\n      // Tell EVERYONE in cluster\n      return socket.broadcast({\n        leaders: this.#scores,\n        timestamp: this.#lastupdate,\n      });\n    }\n  }\n\n  ongossip(msg) {\n    // Return array of tuples: Array\u003c[string, number]\u003e\n    if (msg.type === 'ask:scores') return [...this.#counts];\n    throw new Error(`Missing \"${msg.type}\" handler in ongossip`);\n  }\n\n  // ...\n}\n```\n\n\n## License\n\nMIT © Cloudflare\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcloudflare%2Fdog","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcloudflare%2Fdog","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcloudflare%2Fdog/lists"}