Open10

新しいことを色々試すログ

mizchimizchi

GWなので今まで気になってたが試してなかったことを色々やっていく

mizchimizchi

browser preview + debugger for chrome

ブラウザ内で chrome のプレビューをする。そのときの debugger を vscode の debbugger につなげられる。ちゃんと js で debugger 書くと止まって便利。

https://twitter.com/mizchi/status/1388782906693230593

.vscode/launch.json
{
  "version": "0.1.0",
  "configurations": [
    {
      "type": "browser-preview",
      "request": "attach",
      "name": "Browser Preview: Attach"
    },
    {
      "type": "browser-preview",
      "request": "launch",
      "name": "Browser Preview: Launch",
      "url": "http://localhost:3000"
    }
  ]
}
.vscode/settings.json
{
  "browser-preview.startUrl": "http://localhost:3000"
  // "browser-preview.verbose": false // Enable verbose logging of messages sent between VS Code and Chrome instance
  // "browser-preview.chromeExecutable": // The full path to the executable, including the complete filename of the executable
  // "browser-preview.format": // Option to set the type of rendering with the support for `jpeg` (default one) and `png` formats
  // "browser-preview.ignoreHttpsErrors": false // Ignore HTTPS errors if you are using self-signed SSL certificates
}

この2つの設定を書いてリロードすると有効になった。便利

mizchimizchi

pnpm workspace を使う

最近の yarn が信用できないので pnpm と pnpm の workspace を検証してみる。

Hello from pnpm | pnpm
Workspace | pnpm

npm i -g pnpm 等で pnpm をインストール

pnpm-workspace.yaml
packages:
  - "packages/**"

こういう感じにファイルを配置

packages
├── bar
│   ├── index.js
│   └── package.json
└── foo
    ├── index.js
    └── package.json

bar の dependencies に foo を追加

{
  "name": "bar",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "foo": "1.0.0"
  }
}

この状態で pnpm install

$ pnpm install                                           1  
Scope: all 3 workspace projects
../..                                    | Progress: resolve../..                                    | Progress: resolve../..                                    |  WARN  @sveltejs/vite-plugin-svelte: @rollup/pluginutils@4.1.0 requires a peer of rollup@^1.20.0||^2.0.0 but none was installed.
../..                                    | Progress: resolve../..                                    | Progress: resolved 20, reused 20, downloaded 0, added 0, done

すると、 bar の node_modules の下に foo へのエイリアスが生える。

$ tree packages                                             
packages
├── bar
│   ├── index.js
│   ├── node_modules
│   │   └── foo -> ../../foo
│   └── package.json
└── foo
    ├── index.js
    └── package.json

この状態でこれを実行できる

packages/bar/index.js
require("foo");

この foo への依存は、pnpm の workspace protocol で次のようにも表現できる

  "dependencies": {
    "foo": "workspace:*"
  }

この表現は他の npm cli だと動かないが、 pnpm pubilsh (の pnpm pack) する際は、foo への実際のバージョンに解決される。

mizchimizchi

folio

folio は MS 製テストランナー。 https://github.com/microsoft/playwright-test で知った。TypeScript Friendly で、テストランナー自体に手を入れられて、自由度が高そう。

注意: 現時点のREADME はまだリリースされてない folio@0.4.0 のもので、alpha 版を入れる必要がある。

pnpm でルートに入れる。

pnpm add -D -W folio@0.4.0-alpha6

folio ではテストヘルパを自作する。

test/config.ts
import * as folio from "folio";

folio.setConfig({ testDir: __dirname, timeout: 20000 });

export const test = folio.test;
export const expect = folio.expect;

test.runWith({ retries: 3 });

で、このようにテストを書く。

test/index.test.ts
import { test, expect } from "./config";

test("should pass", async () => {
  expect(1).toBe(1);
});

pnpx folio --config test/config.ts で 実行。

$ pnpm test                                        1s 947ms 

> bar@1.0.0 test /Users/mizchi/proj/plg-20210502/packages/bar
> folio --config test/config.ts


Running 1 test using 1 worker
  1 passed (169ms)

ここまで、 typescript を特に設定していないが、 module: esnext の設定でも勝手に commonjs で実行して流してくれてたりする。

flaky test のリトライ

すでに retries 3 の設定をしているが、 50% の確率で落ちるテストを書いて、それを何度か実行してみる。

test/flaky.test.ts
import { test, expect } from "./config";

test("pass 50%", async () => {
  expect(Math.random() < 0.5).toBeTruthy();
});

落ちるとこうなる。

Running 2 tests using 2 workers
  1 passed (521ms)
  1 flaky
    flaky.test.ts:3:1 › pass 50% ===================================================================

テスト並列化のために、--shard オプションがある

$ npx folio --shard=1/3
$ npx folio --shard=2/3
$ npx folio --shard=3/3

jest は eslint のグローバル変数周りの設定や、 TS の設定に難があったので、TS 決め打ちなら folio でよさそう。jest-circus なしでも retry できるし、shard があるので github actions の並列枠次第で高速化できる。

mizchimizchi

GitHub Actions で pnpm + folio のテストを流す

.github/workflows/test.yaml
name: test
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: "14.x"
      - name: Cache pnpm modules
        uses: actions/cache@v2
        env:
          cache-name: cache-pnpm-modules
        with:
          path: ~/.pnpm-store
          key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/package.json') }}
          restore-keys: |
            ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-
      - uses: pnpm/action-setup@v2
        with:
          version: 6.0.2
          run_install: true
      - run: pnpm test
mizchimizchi

Tailwind JIT

手元のsvelte で試した。

Just-in-Time Mode - Tailwind CSS

mode: 'jit' を足すだけ

tailwind.config.js
module.exports = {
  mode: "jit",
  purge: ["./src/**/*.svelte"],
  theme: {},
  variants: {},
  plugins: [],
};

以下 vite + svelte で使ってみた例

vite.config.js
import { defineConfig } from "vite";
import svelte from "@sveltejs/vite-plugin-svelte";
import autoPreprocess from "svelte-preprocess";

// https://vitejs.dev/config/
export default defineConfig({
  cacheDir: ".vite",
  plugins: [
    svelte({
      preprocess: autoPreprocess({
        postcss: {
          plugins: [
            require("tailwindcss"),
            require("autoprefixer")
          ],
        },
      }),
    }),
  ],
});

このファイルを import してマウントする

src/Tailwind.svelte
<style global lang="postcss">
  @tailwind base;
  @tailwind components;
  @tailwind utilities;
</style>
src/App.svelte
<script lang="ts">
  import Tailwind from "./Tailwind.svelte";
</script>

<Tailwind />

<div class="w-[400px] h-[200px] bg-red-100">400px / 200px</div>

w-[400px] の部分が展開される。

mizchimizchi

react-flow-renderer

ノードベースのグラフエディタ

https://natto.dev/ で使われているのを見て、試してみた

React Flow - Overview Example の Example が TS で型違反起こしていたので、リファクタしながら useCallback 等で綺麗にした

src/components/App.tsx
import React, { useCallback, useState } from "react";

import ReactFlow, {
  removeElements,
  addEdge,
  MiniMap,
  Controls,
  Background,
  OnLoadParams,
  Elements,
  Edge,
  Connection,
} from "react-flow-renderer";

import { initialElements } from "../data/elements";

const OverviewFlow = () => {
  const [elements, setElements] = useState<Elements>(initialElements);
  const onLoad = useCallback((reactFlowInstance: OnLoadParams) => {
    reactFlowInstance.fitView();
  }, []);

  const onElementsRemove = useCallback(
    (elementsToRemove: Elements) =>
      setElements((elements) => removeElements(elementsToRemove, elements)),
    []
  );

  const onConnect = useCallback(
    (params: Edge<any> | Connection) =>
      setElements((els) => addEdge(params, els)),
    []
  );

  return (
    <ReactFlow
      elements={elements}
      onElementsRemove={onElementsRemove}
      onConnect={onConnect}
      onLoad={onLoad}
      snapToGrid={true}
      snapGrid={[15, 15]}
    >
      <MiniMap
        nodeStrokeColor={(n) => {
          if (n.style?.background) return n.style.background as string;
          if (n.type === "input") return "#0041d0";
          if (n.type === "output") return "#ff0072";
          if (n.type === "default") return "#1a192b";
          return "#eee";
        }}
        nodeColor={(n) => {
          if (n.style?.background) return n.style.background as string;
          return "#fff";
        }}
        nodeBorderRadius={2}
      />
      <Controls />
      <Background color="#aaa" gap={16} />
    </ReactFlow>
  );
};

export default OverviewFlow;
src/data/elements.ts
import React from "react";
import type { Elements, Node, Edge } from "react-flow-renderer";
import { ArrowHeadType } from "react-flow-renderer";

export const initialElements: Elements = [
  {
    id: "1",
    type: "input",
    data: {
      label: (
        <>
          Welcome to <strong>React Flow!</strong>
        </>
      ),
    },
    position: { x: 250, y: 0 },
  },
  {
    id: "2",
    data: {
      label: (
        <>
          This is a <strong>default node</strong>
        </>
      ),
    },
    position: { x: 100, y: 100 },
  },
  {
    id: "3",
    data: {
      label: (
        <>
          This one has a <strong>custom style</strong>
        </>
      ),
    },
    position: { x: 400, y: 100 },
    style: {
      background: "#D6D5E6",
      color: "#333",
      border: "1px solid #222138",
      width: 180,
    },
  },
  {
    id: "4",
    position: { x: 250, y: 200 },
    data: {
      label: "Another default node",
    },
  },
  {
    id: "5",
    data: {
      label: "Node id: 5",
    },
    position: { x: 250, y: 325 },
  },
  {
    id: "6",
    type: "output",
    data: {
      label: (
        <>
          An <strong>output node</strong>
        </>
      ),
    },
    position: { x: 100, y: 480 },
  },
  {
    id: "7",
    type: "output",
    data: { label: "Another output node" },
    position: { x: 400, y: 450 },
  },
  { id: "e1-2", source: "1", target: "2", label: "this is an edge label" },
  { id: "e1-3", source: "1", target: "3" },
  {
    id: "e3-4",
    source: "3",
    target: "4",
    animated: true,
    label: "animated edge",
  },
  {
    id: "e4-5",
    source: "4",
    target: "5",
    arrowHeadType: ArrowHeadType.ArrowClosed,
    label: "edge with arrow head",
  },
  {
    id: "e5-6",
    source: "5",
    target: "6",
    type: "smoothstep",
    label: "smooth step edge",
  },
  {
    id: "e5-7",
    source: "5",
    target: "7",
    type: "step",
    style: { stroke: "#f6ab6c" },
    label: "a step edge",
    animated: true,
    labelStyle: { fill: "#f6ab6c", fontWeight: 700 },
  },
];

最小系

これだけだとまだ良くわからなかったので、最小設定にしてみる。

import React from "react";
import type { Elements } from "react-flow-renderer";

export const initialElements: Elements = [
  {
    id: "1",
    type: "input",
    data: {
      label: <>Hello</>,
    },
    position: { x: 20, y: 0 },
  },
  {
    id: "2",
    data: {
      label: <>World</>,
    },
    position: { x: 20, y: 100 },
  },
  {
    id: "3",
    source: "1",
    target: "2",
  },
];

data を持つと node で、 source と target を設定するものが edge。

mizchimizchi

fly.io の postrgres cluster

久しぶりに fly.io を見てみたら、 postgres cluster という新機能が生えていた。やってみる

https://fly.io/docs/reference/postgres/

$ brew install superfly/tap/flyctl
$ flyctl auth signup

# 管理画面からクレカの登録を済ませる
flyctl postgres create

# database url をメモっておく

雑な express アプリを作る

npm init -y
npm install  express pg
server.js
const express = require("express");
const app = express();
const port = process.env.PORT || 3000;
const pg = require("pg");

const client = new pg.Client(
  "<your database>"
);

app.get("/", async (req, res) => {
  await client.connect();
  try {
    const result = await client.query("select *");
    res.json({ done: true, rows: result.rows });
  } catch (err) {
    res.json({ err: err.message });
  }
});

app.listen(port, () => console.log(`HelloNode app listening on port ${port}!`));

deploy して確認する

$ flyctl launch
$ flyctl open # ブラウザで開く

(前は edge worker のサービスだったが、今は普通のSaaS っぽくなっている?)

ブラウザで開いてみる

{"err":"SELECT * with no tables specified is not valid"}

なんのデータベースも作ってないので、とりあえずデータベースを生成できてることは確認

mizchimizchi

Cloudflare Workers: Durable Objects

Public Beta が来ていたので試す。Durable Objects 自体の解説はこちら Cloudflare Workers の Durable Objects について

最初に、 Cloudflare の Dashboard から、 Dashboard > Durable Objects から 有効化する。本番で使うなよ、みたいな警告が出る。 https://dash.cloudflare.com/[account_id]/workers/durable-objects みたいなURLのはず。この作業をしないと、後でデプロイしようとした時に次のエラーが出る。

Error: Something went wrong! Status: 403 Forbidden, Details {
  "result": null,
  "success": false,
  "errors": [
    {
      "code": 10015,
      "message": "workers.api.error.not_entitled"
    }
  ],
  "messages": []
}

詳細: Workers Error 10015 workers.api.error.not_entitled - Developers / Workers - Cloudflare Community

それでは作業をしていく。 wrangler cli の durable objects 対応版をインストールする

npm install -g @cloudflare/wrangler@1.16.0-durable-objects-rc.0

試した限り、最新の 1.16.1 はだめ。

ボイラープレートから初期化する。

$ wangler generate [your-wokrer-name] https://github.com/cloudflare/durable-objects-template

install 時に注意が出るが、wrangler.toml の account_id に自分の id をセットする。

とりあえずデプロイしてみる。

$ wrangler publish --new-class Counter
✨  No build command specified, skipping build.
✨  Successfully published your script to
 https://durable-objects-mizchi-example.mizchi.workers.dev

(このサービスは後で消す)

https://durable-objects-mizchi-example.mizchi.workers.dev にアクセス

https://durable-objects-mizchi-example.mizchi.workers.dev/increment にアクセスすると数値が 1 増える。

https://durable-objects-mizchi-example.mizchi.workers.dev/decrement にアクセスすると数値が 1 減る。

コードを読む

package.json では、"module": "./src/index.mjs" でエントリポイントが指定されている。そのコードがこれ

src/index.mjs

// Worker

export default {
  async fetch(request, env) {
    return await handleRequest(request, env);
  }
}

async function handleRequest(request, env) {
  let id = env.COUNTER.idFromName("A");
  let obj = env.COUNTER.get(id);
  let resp = await obj.fetch(request.url);
  let count = await resp.text();

  return new Response("Durable Object 'A' count: " + count);
}

// Durable Object

export class Counter {
  constructor(state, env) {
    this.state = state;
  }

  async initialize() {
    let stored = await this.state.storage.get("value");
    this.value = stored || 0;
  }

  // Handle HTTP requests from clients.
  async fetch(request) {
    // Make sure we're fully initialized from storage.
    if (!this.initializePromise) {
      this.initializePromise = this.initialize().catch((err) => {
        // If anything throws during initialization then we need to be
        // sure sure that a future request will retry initialize().
        // Note that the concurrency involved in resetting this shared
        // promise on an error can be tricky to get right -- we don't
        // recommend customizing it.
        this.initializePromise = undefined;
        throw err
      });
    }
    await this.initializePromise;

    // Apply requested action.
    let url = new URL(request.url);
    let currentValue = this.value;
    switch (url.pathname) {
    case "/increment":
      currentValue = ++this.value;
      await this.state.storage.put("value", this.value);
      break;
    case "/decrement":
      currentValue = --this.value;
      await this.state.storage.put("value", this.value);
      break;
    case "/":
      // Just serve the current value. No storage calls needed!
      break;
    default:
      return new Response("Not found", {status: 404});
    }

    // Return `currentValue`. Note that `this.value` may have been
    // incremented or decremented by a concurrent request when we
    // yielded the event loop to `await` the `storage.put` above!
    // That's why we stored the counter value created by this
    // request in `currentValue` before we used `await`.
    return new Response(currentValue);
  }
}

いつもの cloudflare workers と書き方が違うが、次のコードが onfetch に相当している。

export default {
  async fetch(request, env) {
    return await handleRequest(request, env);
  }
}

handleRequest はこういう実装で、env.Counter で Counter を参照している。

async function handleRequest(request, env) {
  let id = env.COUNTER.idFromName("A");
  let obj = env.COUNTER.get(id);
  let resp = await obj.fetch(request.url);
  let count = await resp.text();

  return new Response("Durable Object 'A' count: " + count);
}

idFromName は ルームID みたいなものだろうか。obj でカウンターの実体を手に入れて、そこに obj.fetch を投げている。

Counter の実装がこちら

export class Counter {
  constructor(state, env) {
    this.state = state;
  }

  async initialize() {
    let stored = await this.state.storage.get("value");
    this.value = stored || 0;
  }

  // Handle HTTP requests from clients.
  async fetch(request) {
    // Make sure we're fully initialized from storage.
    if (!this.initializePromise) {
      this.initializePromise = this.initialize().catch((err) => {
        // If anything throws during initialization then we need to be
        // sure sure that a future request will retry initialize().
        // Note that the concurrency involved in resetting this shared
        // promise on an error can be tricky to get right -- we don't
        // recommend customizing it.
        this.initializePromise = undefined;
        throw err
      });
    }
    await this.initializePromise;

    // Apply requested action.
    let url = new URL(request.url);
    let currentValue = this.value;
    switch (url.pathname) {
    case "/increment":
      currentValue = ++this.value;
      await this.state.storage.put("value", this.value);
      break;
    case "/decrement":
      currentValue = --this.value;
      await this.state.storage.put("value", this.value);
      break;
    case "/":
      // Just serve the current value. No storage calls needed!
      break;
    default:
      return new Response("Not found", {status: 404});
    }

    // Return `currentValue`. Note that `this.value` may have been
    // incremented or decremented by a concurrent request when we
    // yielded the event loop to `await` the `storage.put` above!
    // That's why we stored the counter value created by this
    // request in `currentValue` before we used `await`.
    return new Response(currentValue);
  }
}

この Counter クラスのメンバがシリアライズされてそう。

実際のカウンターの操作をしているのがここ

    // Apply requested action.
    let url = new URL(request.url);
    let currentValue = this.value;
    switch (url.pathname) {
    case "/increment":
      currentValue = ++this.value;
      await this.state.storage.put("value", this.value);
      break;
    case "/decrement":
      currentValue = --this.value;
      await this.state.storage.put("value", this.value);
      break;
    case "/":
      // Just serve the current value. No storage calls needed! 
      break;
    default:
      return new Response("Not found", {status: 404});
    }

state object に対して await this.state.storage.put("value", this.value); で書き込んでいる。

使い方は Workers KV に似ているが、 Workers KV が結果整合だが Durable Objects は強整合なのが大きな違い。

より詳しい解説はここ

https://developers.cloudflare.com/workers/learning/using-durable-objects

mizchimizchi

Cloudflare Durable Objects を WebSocket で使う

https://github.com/cloudflare/workers-chat-demo を読む。

chat.html の中の script では、このように接続しようとしている。

src/chat.html-script.js
let ws = new WebSocket("wss://" + hostname + "/api/room/" + roomname + "/websocket");

なので、 /api/room/:roomname/websocket をエンドポイントして WS が実装されてるはず。

それが src/chat.mjs のこの部分
https://github.com/cloudflare/workers-chat-demo/blob/master/src/chat.mjs#L233-L266

class ChatRoom {
  // ...
  async fetch(request) {
    return await handleErrors(request, async () => {
      let url = new URL(request.url);

      switch (url.pathname) {
        case "/websocket": {
          // The request is to `/api/room/<name>/websocket`. A client is trying to establish a new
          // WebSocket session.
          if (request.headers.get("Upgrade") != "websocket") {
            return new Response("expected websocket", {status: 400});
          }

          // Get the client's IP address for use with the rate limiter.
          let ip = request.headers.get("CF-Connecting-IP");

          // To accept the WebSocket request, we create a WebSocketPair (which is like a socketpair,
          // i.e. two WebSockets that talk to each other), we return one end of the pair in the
          // response, and we operate on the other end. Note that this API is not part of the
          // Fetch API standard; unfortunately, the Fetch API / Service Workers specs do not define
          // any way to act as a WebSocket server today.
          let pair = new WebSocketPair();

          // We're going to take pair[1] as our end, and return pair[0] to the client.
          await this.handleSession(pair[1], ip);

          // Now we return the other end of the pair to the client.
          return new Response(null, { status: 101, webSocket: pair[0] });
        }

        default:
          return new Response("Not found", {status: 404});
      }
    });
  }

WebSocketPair というのが、クラサバ2つの socket を生成している部分で、入力の片方はサーバーでハンドルして、もう片方は websocket コネクションとして 101 Switching Protocol でプロトコルを切り替えるように返却する?っぽいAPIになっている。

https://developers.cloudflare.com/workers/runtime-apis/websockets

101 Switching Protocols - HTTP | MDN

サーバーで websocket セッションを確立する部分。

  async handleSession(webSocket, ip) {
    // Accept our end of the WebSocket. This tells the runtime that we'll be terminating the
    // WebSocket in JavaScript, not sending it elsewhere.
    webSocket.accept();

    // Set up our rate limiter client.
    let limiterId = this.env.limiters.idFromName(ip);
    let limiter = new RateLimiterClient(
        () => this.env.limiters.get(limiterId),
        err => webSocket.close(1011, err.stack));

    // Create our session and add it to the sessions list.
    // We don't send any messages to the client until it has sent us the initial user info
    // message. Until then, we will queue messages in `session.blockedMessages`.
    let session = {webSocket, blockedMessages: []};
    this.sessions.push(session);

    // Queue "join" messages for all online users, to populate the client's roster.
    this.sessions.forEach(otherSession => {
      if (otherSession.name) {
        session.blockedMessages.push(JSON.stringify({joined: otherSession.name}));
      }
    });

    // Load the last 100 messages from the chat history stored on disk, and send them to the
    // client.
    let storage = await this.storage.list({reverse: true, limit: 100});
    let backlog = [...storage.values()];
    backlog.reverse();
    backlog.forEach(value => {
      session.blockedMessages.push(value);
    });

    // Set event handlers to receive messages.
    let receivedUserInfo = false;
    webSocket.addEventListener("message", async msg => {
      try {
        if (session.quit) {
          // Whoops, when trying to send to this WebSocket in the past, it threw an exception and
          // we marked it broken. But somehow we got another message? I guess try sending a
          // close(), which might throw, in which case we'll try to send an error, which will also
          // throw, and whatever, at least we won't accept the message. (This probably can't
          // actually happen. This is defensive coding.)
          webSocket.close(1011, "WebSocket broken.");
          return;
        }

        // Check if the user is over their rate limit and reject the message if so.
        if (!limiter.checkLimit()) {
          webSocket.send(JSON.stringify({
            error: "Your IP is being rate-limited, please try again later."
          }));
          return;
        }

        // I guess we'll use JSON.
        let data = JSON.parse(msg.data);

        if (!receivedUserInfo) {
          // The first message the client sends is the user info message with their name. Save it
          // into their session object.
          session.name = "" + (data.name || "anonymous");

          // Don't let people use ridiculously long names. (This is also enforced on the client,
          // so if they get here they are not using the intended client.)
          if (session.name.length > 32) {
            webSocket.send(JSON.stringify({error: "Name too long."}));
            webSocket.close(1009, "Name too long.");
            return;
          }

          // Deliver all the messages we queued up since the user connected.
          session.blockedMessages.forEach(queued => {
            webSocket.send(queued);
          });
          delete session.blockedMessages;

          // Broadcast to all other connections that this user has joined.
          this.broadcast({joined: session.name});

          webSocket.send(JSON.stringify({ready: true}));

          // Note that we've now received the user info message.
          receivedUserInfo = true;

          return;
        }

        // Construct sanitized message for storage and broadcast.
        data = { name: session.name, message: "" + data.message };

        // Block people from sending overly long messages. This is also enforced on the client,
        // so to trigger this the user must be bypassing the client code.
        if (data.message.length > 256) {
          webSocket.send(JSON.stringify({error: "Message too long."}));
          return;
        }

        // Add timestamp. Here's where this.lastTimestamp comes in -- if we receive a bunch of
        // messages at the same time (or if the clock somehow goes backwards????), we'll assign
        // them sequential timestamps, so at least the ordering is maintained.
        data.timestamp = Math.max(Date.now(), this.lastTimestamp + 1);
        this.lastTimestamp = data.timestamp;

        // Broadcast the message to all other WebSockets.
        let dataStr = JSON.stringify(data);
        this.broadcast(dataStr);

        // Save message.
        let key = new Date(data.timestamp).toISOString();
        await this.storage.put(key, dataStr);
      } catch (err) {
        // Report any exceptions directly back to the client. As with our handleErrors() this
        // probably isn't what you'd want to do in production, but it's convenient when testing.
        webSocket.send(JSON.stringify({error: err.stack}));
      }
    });

    // On "close" and "error" events, remove the WebSocket from the sessions list and broadcast
    // a quit message.
    let closeOrErrorHandler = evt => {
      session.quit = true;
      this.sessions = this.sessions.filter(member => member !== session);
      if (session.name) {
        this.broadcast({quit: session.name});
      }
    };
    webSocket.addEventListener("close", closeOrErrorHandler);
    webSocket.addEventListener("error", closeOrErrorHandler);
  }

let storage = await this.storage.list({reverse: true, limit: 100}); は 100件保持するように予約しているっぽい。

RateLimitter は更新頻度に制限を掛けているモジュールだが、これも Durable Objects として実装されてる。

export class RateLimiter {
  constructor(controller, env) {
    // Timestamp at which this IP will next be allowed to send a message. Start in the distant
    // past, i.e. the IP can send a message now.
    this.nextAllowedTime = 0;
  }

  // Our protocol is: POST when the IP performs an action, or GET to simply read the current limit.
  // Either way, the result is the number of seconds to wait before allowing the IP to perform its
  // next action.
  async fetch(request) {
    return await handleErrors(request, async () => {
      let now = Date.now() / 1000;

      this.nextAllowedTime = Math.max(now, this.nextAllowedTime);

      if (request.method == "POST") {
        // POST request means the user performed an action.
        // We allow one action per 5 seconds.
        this.nextAllowedTime += 5;
      }

      // Return the number of seconds that the client needs to wait.
      //
      // We provide a "grace" period of 20 seconds, meaning that the client can make 4-5 requests
      // in a quick burst before they start being limited.
      let cooldown = Math.max(0, this.nextAllowedTime - now - 20);
      return new Response(cooldown);
    })
  }
}

低レベルに見えるが、大事なのは.WebSocketPair で 2つの socket が生成されるから、それをよしなに実装するのと、どのように export class されたオブジェクトが永続化されるかを覚えるっぽい。