新しいことを色々試すログ
GWなので今まで気になってたが試してなかったことを色々やっていく
browser preview + debugger for chrome
- https://github.com/auchenberg/vscode-browser-preview
- https://marketplace.visualstudio.com/items?itemName=msjsdiag.debugger-for-chrome
ブラウザ内で chrome のプレビューをする。そのときの debugger を vscode の debbugger につなげられる。ちゃんと js で debugger 書くと止まって便利。
{
"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"
}
]
}
{
"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つの設定を書いてリロードすると有効になった。便利
pnpm workspace を使う
最近の yarn が信用できないので pnpm と pnpm の workspace を検証してみる。
Hello from pnpm | pnpm
Workspace | pnpm
npm i -g pnpm
等で pnpm をインストール
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
この状態でこれを実行できる
require("foo");
この foo への依存は、pnpm の workspace protocol で次のようにも表現できる
"dependencies": {
"foo": "workspace:*"
}
この表現は他の npm cli だと動かないが、 pnpm pubilsh (の pnpm pack) する際は、foo への実際のバージョンに解決される。
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 ではテストヘルパを自作する。
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 });
で、このようにテストを書く。
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% の確率で落ちるテストを書いて、それを何度か実行してみる。
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 の並列枠次第で高速化できる。
GitHub Actions で pnpm + folio のテストを流す
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
Tailwind JIT
手元のsvelte で試した。
Just-in-Time Mode - Tailwind CSS
mode: 'jit' を足すだけ
module.exports = {
mode: "jit",
purge: ["./src/**/*.svelte"],
theme: {},
variants: {},
plugins: [],
};
以下 vite + svelte で使ってみた例
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 してマウントする
<style global lang="postcss">
@tailwind base;
@tailwind components;
@tailwind utilities;
</style>
<script lang="ts">
import Tailwind from "./Tailwind.svelte";
</script>
<Tailwind />
<div class="w-[400px] h-[200px] bg-red-100">400px / 200px</div>
w-[400px]
の部分が展開される。
react-flow-renderer
ノードベースのグラフエディタ
https://natto.dev/ で使われているのを見て、試してみた
React Flow - Overview Example の Example が TS で型違反起こしていたので、リファクタしながら useCallback 等で綺麗にした
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;
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。
fly.io の postrgres cluster
久しぶりに fly.io を見てみたら、 postgres cluster という新機能が生えていた。やってみる
$ brew install superfly/tap/flyctl
$ flyctl auth signup
# 管理画面からクレカの登録を済ませる
flyctl postgres create
# database url をメモっておく
雑な express アプリを作る
npm init -y
npm install express pg
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"}
なんのデータベースも作ってないので、とりあえずデータベースを生成できてることは確認
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"
でエントリポイントが指定されている。そのコードがこれ
// 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 は強整合なのが大きな違い。
より詳しい解説はここ
Cloudflare Durable Objects を WebSocket で使う
https://github.com/cloudflare/workers-chat-demo を読む。
chat.html の中の script では、このように接続しようとしている。
let ws = new WebSocket("wss://" + hostname + "/api/room/" + roomname + "/websocket");
なので、 /api/room/:roomname/websocket
をエンドポイントして WS が実装されてるはず。
それが src/chat.mjs のこの部分
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になっている。
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 されたオブジェクトが永続化されるかを覚えるっぽい。