homura-runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +6 -0
- data/README.md +37 -0
- data/bin/cloudflare-workers-build +255 -0
- data/docs/ARCHITECTURE.md +59 -0
- data/exe/auto-await +102 -0
- data/exe/compile-assets +142 -0
- data/exe/compile-erb +262 -0
- data/lib/cloudflare_workers/ai.rb +197 -0
- data/lib/cloudflare_workers/async_registry.rb +198 -0
- data/lib/cloudflare_workers/auto_await/analyzer.rb +264 -0
- data/lib/cloudflare_workers/auto_await/transformer.rb +19 -0
- data/lib/cloudflare_workers/cache.rb +234 -0
- data/lib/cloudflare_workers/durable_object.rb +590 -0
- data/lib/cloudflare_workers/email.rb +180 -0
- data/lib/cloudflare_workers/http.rb +164 -0
- data/lib/cloudflare_workers/multipart.rb +332 -0
- data/lib/cloudflare_workers/queue.rb +407 -0
- data/lib/cloudflare_workers/scheduled.rb +185 -0
- data/lib/cloudflare_workers/stream.rb +317 -0
- data/lib/cloudflare_workers/version.rb +5 -0
- data/lib/cloudflare_workers.rb +801 -0
- data/lib/opal_patches.rb +653 -0
- data/runtime/patch-opal-evals.mjs +66 -0
- data/runtime/setup-node-crypto.mjs +20 -0
- data/runtime/worker.mjs +9 -0
- data/runtime/worker_module.mjs +384 -0
- data/runtime/wrangler.toml.example +40 -0
- data/templates/wrangler.toml.example +8 -0
- metadata +104 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phase 11A — rewrite Opal runtime `eval(...)` calls to `globalThis.eval(...)`.
|
|
3
|
+
//
|
|
4
|
+
// The Opal stdlib includes an `eval` / `require_remote` code path used
|
|
5
|
+
// only for interactive / IRB scenarios (Opal.compile → eval at
|
|
6
|
+
// runtime). On Workers we pre-compile every .rb file at build time, so
|
|
7
|
+
// those eval sites never actually fire. esbuild still sees them
|
|
8
|
+
// lexically and emits a "direct eval" warning (which wrangler echoes
|
|
9
|
+
// as red `ERROR` rows). The calls are semantically identical to
|
|
10
|
+
// `globalThis.eval(...)` in these positions (they don't depend on the
|
|
11
|
+
// enclosing lexical scope), and `globalThis.eval` is the
|
|
12
|
+
// spec-blessed way to ask for indirect eval — which esbuild doesn't
|
|
13
|
+
// warn about.
|
|
14
|
+
//
|
|
15
|
+
// Phase 15-A: generic CLI — pass bundle path as first positional or
|
|
16
|
+
// `--input PATH` (defaults to build/hello.no-exit.mjs).
|
|
17
|
+
//
|
|
18
|
+
// This script is invoked as a post-opal step by `npm run build:opal`
|
|
19
|
+
// (via the build pipeline). Runs fast (single regex pass) and is
|
|
20
|
+
// idempotent (re-running on already-patched output is a no-op).
|
|
21
|
+
//
|
|
22
|
+
// We match `eval(` only when preceded by a non-identifier character
|
|
23
|
+
// so we don't rewrite `Kernel#$eval`, `instance_eval`, `module_eval`,
|
|
24
|
+
// `self.$eval`, etc. — those are property accesses / method names,
|
|
25
|
+
// not the global function.
|
|
26
|
+
|
|
27
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { parseArgs } from "node:util";
|
|
29
|
+
|
|
30
|
+
const { values, positionals } = parseArgs({
|
|
31
|
+
args: process.argv.slice(2),
|
|
32
|
+
options: {
|
|
33
|
+
input: { type: "string", short: "i" },
|
|
34
|
+
},
|
|
35
|
+
allowPositionals: true,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const path =
|
|
39
|
+
values.input ||
|
|
40
|
+
positionals[0] ||
|
|
41
|
+
process.env.HOMURA_OPAL_PATCH_INPUT ||
|
|
42
|
+
"build/hello.no-exit.mjs";
|
|
43
|
+
|
|
44
|
+
const src = readFileSync(path, "utf8");
|
|
45
|
+
|
|
46
|
+
// Boundary class: must not be [.$a-zA-Z0-9_]. We include
|
|
47
|
+
// `\b(?<!globalThis\.)` negative lookbehind isn't supported everywhere,
|
|
48
|
+
// so we instead do a two-step: first replace `(^|[^...])eval(` then
|
|
49
|
+
// revert any accidental double-rewrite of `globalThis.eval(`.
|
|
50
|
+
const before = /(^|[^.$a-zA-Z0-9_])eval\(/gm;
|
|
51
|
+
const after = "$1globalThis.eval(";
|
|
52
|
+
let out = src.replace(before, after);
|
|
53
|
+
|
|
54
|
+
// Guard against accidental `globalThis.globalThis.eval(` if this script
|
|
55
|
+
// is run twice (defensive — replace() above is already idempotent
|
|
56
|
+
// because `.globalThis.eval(` starts with `.` which fails the
|
|
57
|
+
// boundary, but belt-and-suspenders).
|
|
58
|
+
out = out.replace(/globalThis\.globalThis\.eval\(/g, "globalThis.eval(");
|
|
59
|
+
|
|
60
|
+
const changes = (out.match(/globalThis\.eval\(/g) || []).length;
|
|
61
|
+
if (out === src) {
|
|
62
|
+
console.log(`[patch-opal-evals] no changes needed (${path})`);
|
|
63
|
+
} else {
|
|
64
|
+
writeFileSync(path, out);
|
|
65
|
+
console.log(`[patch-opal-evals] rewrote ${changes} direct eval → globalThis.eval (${path})`);
|
|
66
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Phase 7 — bootstrap that exposes node:crypto on globalThis so the
|
|
2
|
+
// Opal-compiled Ruby bundle can use synchronous hash / hmac / cipher /
|
|
3
|
+
// pkey / kdf APIs without async/await glue.
|
|
4
|
+
//
|
|
5
|
+
// Cloudflare Workers exposes node:crypto when `compatibility_flags`
|
|
6
|
+
// includes "nodejs_compat" (already enabled in wrangler.toml). Node
|
|
7
|
+
// itself ships node:crypto natively, so the same import works in:
|
|
8
|
+
//
|
|
9
|
+
// - Production (Cloudflare Workers + nodejs_compat)
|
|
10
|
+
// - Test (Node.js, via `node --import ./gems/homura-runtime/runtime/setup-node-crypto.mjs`)
|
|
11
|
+
//
|
|
12
|
+
// Why globalThis: Opal-emitted ESM modules cannot easily declare new
|
|
13
|
+
// `import` statements after the build. Setting a global lets every
|
|
14
|
+
// Ruby code path reach the same crypto module via a single backtick:
|
|
15
|
+
//
|
|
16
|
+
// `globalThis.__nodeCrypto__.createHash('sha256')`
|
|
17
|
+
|
|
18
|
+
import nodeCrypto from "node:crypto";
|
|
19
|
+
|
|
20
|
+
globalThis.__nodeCrypto__ = nodeCrypto;
|
data/runtime/worker.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Cloudflare Workers Module Worker — thin bootstrap (Phase 15-E).
|
|
2
|
+
// Order: node:crypto shim → Opal bundle side effects → Rack/Queue/DO handlers.
|
|
3
|
+
//
|
|
4
|
+
// Prefer `build/worker.entrypoint.mjs` as wrangler `main` in application repos;
|
|
5
|
+
// this file remains valid for monorepo layouts that point `main` at the gem path.
|
|
6
|
+
|
|
7
|
+
import "./setup-node-crypto.mjs";
|
|
8
|
+
import "../../../build/hello.no-exit.mjs";
|
|
9
|
+
export { default, HomuraCounterDO } from "./worker_module.mjs";
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// Rack / Cron / Queue / DO adapters for Cloudflare Workers (no Opal bundle import).
|
|
2
|
+
// Load order: setup-node-crypto.mjs → Opal bundle (side effects) → this module.
|
|
3
|
+
// Phase 15-E: split from worker.mjs so generated worker.entrypoint.mjs can use
|
|
4
|
+
// fixed relative imports to the build artifact.
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Phase 15-A — dispatch resolution: prefer __OPAL_WORKERS__, alias legacy
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function rackDispatch() {
|
|
11
|
+
const ow = globalThis.__OPAL_WORKERS__;
|
|
12
|
+
const fn = ow && typeof ow.rack === "function" ? ow.rack : undefined;
|
|
13
|
+
return fn || globalThis.__HOMURA_RACK_DISPATCH__;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Phase 17 — Workers `env.SEND_EMAIL` を Rack 構築より前に global に載せ、Miniflare でも Ruby が同じバインディングを拾えるようにする。 */
|
|
17
|
+
function ensureOpalWorkersEmailBinding(env) {
|
|
18
|
+
const ow = (globalThis.__OPAL_WORKERS__ ||= {});
|
|
19
|
+
if (env && env.SEND_EMAIL) {
|
|
20
|
+
ow.sendEmailBinding = env.SEND_EMAIL;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Miniflare の entry.worker は `/cdn-cgi/mf/scheduled` だけ先に処理する。
|
|
26
|
+
* `/cdn-cgi/handler/email` 等はユーザ Worker の fetch に届くため、ここで Rack に渡さず処理する。
|
|
27
|
+
* (Cloudflare Email Routing — Local Development の受信スタブ向け。Phase 18 で `email()` と接続予定)
|
|
28
|
+
*/
|
|
29
|
+
async function handleCdnCgiBypass(request, env, _ctx) {
|
|
30
|
+
const url = new URL(request.url);
|
|
31
|
+
if (url.pathname === "/cdn-cgi/handler/email") {
|
|
32
|
+
if (request.method === "POST") {
|
|
33
|
+
try {
|
|
34
|
+
globalThis.console.log(
|
|
35
|
+
"[homura] POST /cdn-cgi/handler/email — bypass Rack (Email Routing local-dev stub path)",
|
|
36
|
+
);
|
|
37
|
+
} catch (_e) {}
|
|
38
|
+
return new Response(
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
ok: true,
|
|
41
|
+
bypass: "rack",
|
|
42
|
+
pathname: url.pathname,
|
|
43
|
+
note: "homura worker_module forwards /cdn-cgi away from Sinatra. Full inbound handling: Phase 18 (export email()).",
|
|
44
|
+
}),
|
|
45
|
+
{ status: 200, headers: { "content-type": "application/json; charset=utf-8" } },
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return new Response("Method Not Allowed", { status: 405 });
|
|
49
|
+
}
|
|
50
|
+
if (env.ASSETS && typeof env.ASSETS.fetch === "function") {
|
|
51
|
+
return env.ASSETS.fetch(request);
|
|
52
|
+
}
|
|
53
|
+
return new Response("not handled", { status: 404, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function scheduledDispatch() {
|
|
57
|
+
const ow = globalThis.__OPAL_WORKERS__;
|
|
58
|
+
const fn = ow && typeof ow.scheduled === "function" ? ow.scheduled : undefined;
|
|
59
|
+
return fn || globalThis.__HOMURA_SCHEDULED_DISPATCH__;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function queueDispatch() {
|
|
63
|
+
const ow = globalThis.__OPAL_WORKERS__;
|
|
64
|
+
const fn = ow && typeof ow.queue === "function" ? ow.queue : undefined;
|
|
65
|
+
return fn || globalThis.__HOMURA_QUEUE_DISPATCH__;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function durableObjectDispatch() {
|
|
69
|
+
const d = globalThis.__OPAL_WORKERS__ && globalThis.__OPAL_WORKERS__.durableObject;
|
|
70
|
+
const fn = d && typeof d.dispatch === "function" ? d.dispatch : undefined;
|
|
71
|
+
return fn || globalThis.__HOMURA_DO_DISPATCH__;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function durableObjectWsMessage() {
|
|
75
|
+
const d = globalThis.__OPAL_WORKERS__ && globalThis.__OPAL_WORKERS__.durableObject;
|
|
76
|
+
const fn = d && typeof d.wsMessage === "function" ? d.wsMessage : undefined;
|
|
77
|
+
return fn || globalThis.__HOMURA_DO_WS_MESSAGE__;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function durableObjectWsClose() {
|
|
81
|
+
const d = globalThis.__OPAL_WORKERS__ && globalThis.__OPAL_WORKERS__.durableObject;
|
|
82
|
+
const fn = d && typeof d.wsClose === "function" ? d.wsClose : undefined;
|
|
83
|
+
return fn || globalThis.__HOMURA_DO_WS_CLOSE__;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function durableObjectWsError() {
|
|
87
|
+
const d = globalThis.__OPAL_WORKERS__ && globalThis.__OPAL_WORKERS__.durableObject;
|
|
88
|
+
const fn = d && typeof d.wsError === "function" ? d.wsError : undefined;
|
|
89
|
+
return fn || globalThis.__HOMURA_DO_WS_ERROR__;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Phase 11A — binary-safe body passthrough. Convert an ArrayBuffer of
|
|
93
|
+
// request body bytes into a latin1 String (each code unit 0–255 is
|
|
94
|
+
// exactly one byte). Opal Strings are JS Strings (UTF-16), but a
|
|
95
|
+
// latin1-encoded string survives through StringIO / Ruby unchanged.
|
|
96
|
+
// The Ruby side (`Cloudflare::Multipart`) reads those bytes and, for
|
|
97
|
+
// file parts, converts them back to a real Uint8Array when passing
|
|
98
|
+
// to R2.put / fetch body.
|
|
99
|
+
//
|
|
100
|
+
// Chunked to avoid the `Maximum call stack size exceeded` hazard of
|
|
101
|
+
// `String.fromCharCode.apply(null, hugeArray)` — the ~0xFFFE arg cap
|
|
102
|
+
// differs per engine, so we stay comfortably below at 0x8000.
|
|
103
|
+
function binaryArrayBufferToLatin1String(arrayBuffer) {
|
|
104
|
+
const u8 = new Uint8Array(arrayBuffer);
|
|
105
|
+
const CHUNK = 0x8000;
|
|
106
|
+
// Accumulate into an array and join once at the end — in-loop
|
|
107
|
+
// String concatenation is O(n²) on V8 for large uploads. Each
|
|
108
|
+
// `chunk` here is already a small String (≤ 32768 chars), so
|
|
109
|
+
// join() can reuse rope structures efficiently.
|
|
110
|
+
const parts = [];
|
|
111
|
+
for (let i = 0; i < u8.length; i += CHUNK) {
|
|
112
|
+
const slice = u8.subarray(i, Math.min(i + CHUNK, u8.length));
|
|
113
|
+
parts.push(String.fromCharCode.apply(null, slice));
|
|
114
|
+
}
|
|
115
|
+
return parts.join("");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Module Worker `fetch` and Durable Object HTTP `fetch` must use the same body
|
|
119
|
+
// semantics: multipart/form-data uses arrayBuffer + latin1 (Phase 11A), not UTF-8 text().
|
|
120
|
+
async function readBodyTextForRubyDispatcher(request) {
|
|
121
|
+
const method = (request && request.method ? request.method : "GET").toUpperCase();
|
|
122
|
+
if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
|
|
123
|
+
return { bodyText: "" };
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const contentType = request.headers.get("content-type") || "";
|
|
127
|
+
if (contentType.toLowerCase().includes("multipart/")) {
|
|
128
|
+
const buf = await request.arrayBuffer();
|
|
129
|
+
return { bodyText: binaryArrayBufferToLatin1String(buf) };
|
|
130
|
+
}
|
|
131
|
+
return { bodyText: await request.text() };
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return {
|
|
134
|
+
bodyReadError: new Response(
|
|
135
|
+
JSON.stringify({ error: "failed to read request body", detail: err.message }),
|
|
136
|
+
{ status: 400, headers: { "content-type": "application/json" } },
|
|
137
|
+
),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export default {
|
|
143
|
+
async fetch(request, env, ctx) {
|
|
144
|
+
ensureOpalWorkersEmailBinding(env);
|
|
145
|
+
|
|
146
|
+
let reqUrl;
|
|
147
|
+
try {
|
|
148
|
+
reqUrl = new URL(request.url);
|
|
149
|
+
} catch (_e) {
|
|
150
|
+
reqUrl = { pathname: "/" };
|
|
151
|
+
}
|
|
152
|
+
if (reqUrl.pathname.startsWith("/cdn-cgi/")) {
|
|
153
|
+
return handleCdnCgiBypass(request, env, ctx);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const dispatch = rackDispatch();
|
|
157
|
+
if (typeof dispatch !== "function") {
|
|
158
|
+
return new Response(
|
|
159
|
+
"homura: Rack dispatcher not installed (Rack::Handler::CloudflareWorkers.run never called)\n",
|
|
160
|
+
{ status: 500, headers: { "content-type": "text/plain; charset=utf-8" } },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Read the body here while we still have the async context. The Opal
|
|
165
|
+
// side runs synchronous Ruby, so it cannot `await req.text()` inside
|
|
166
|
+
// the dispatcher. For methods that are defined to have no body
|
|
167
|
+
// (GET, HEAD, OPTIONS by spec) we skip the read entirely to avoid
|
|
168
|
+
// wasting a round-trip.
|
|
169
|
+
//
|
|
170
|
+
const bodyResult = await readBodyTextForRubyDispatcher(request);
|
|
171
|
+
if (bodyResult.bodyReadError) {
|
|
172
|
+
return bodyResult.bodyReadError;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return dispatch(request, env, ctx, bodyResult.bodyText);
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
// Phase 9 — Cloudflare Workers Cron Triggers entry point.
|
|
179
|
+
//
|
|
180
|
+
// The Workers runtime invokes this `scheduled` export once per
|
|
181
|
+
// matching `[triggers] crons` entry in wrangler.toml. We forward
|
|
182
|
+
// the (event, env, ctx) triple to the Ruby-side dispatcher
|
|
183
|
+
// installed by `lib/cloudflare_workers/scheduled.rb`
|
|
184
|
+
// (which registers `globalThis.__HOMURA_SCHEDULED_DISPATCH__`).
|
|
185
|
+
//
|
|
186
|
+
// The Ruby dispatcher walks every job registered via the
|
|
187
|
+
// Sinatra DSL `schedule '*/5 * * * *' do ... end` and runs
|
|
188
|
+
// each one whose cron pattern matches `event.cron`.
|
|
189
|
+
//
|
|
190
|
+
// Local manual triggering (no cron poll wait):
|
|
191
|
+
// wrangler dev --test-scheduled
|
|
192
|
+
// curl 'http://127.0.0.1:8787/__scheduled?cron=*/5+*+*+*+*'
|
|
193
|
+
//
|
|
194
|
+
// Tip: ALWAYS call ctx.waitUntil on long-running work so the
|
|
195
|
+
// Workers runtime keeps the isolate alive past the dispatcher's
|
|
196
|
+
// synchronous return. The Ruby helper `wait_until(promise)` does
|
|
197
|
+
// exactly that.
|
|
198
|
+
async scheduled(event, env, ctx) {
|
|
199
|
+
const dispatch = scheduledDispatch();
|
|
200
|
+
if (typeof dispatch !== "function") {
|
|
201
|
+
// No Ruby dispatcher installed — the Opal bundle didn't
|
|
202
|
+
// require 'cloudflare_workers/scheduled'. Log loudly so this
|
|
203
|
+
// misconfiguration surfaces instead of silently dropping
|
|
204
|
+
// every cron firing.
|
|
205
|
+
try {
|
|
206
|
+
globalThis.console.error(
|
|
207
|
+
"homura: scheduled dispatcher not installed (require 'cloudflare_workers/scheduled' missing)",
|
|
208
|
+
);
|
|
209
|
+
} catch (e) {
|
|
210
|
+
// ignore — console may itself be broken in pathological cases
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Hand the work to ctx.waitUntil so an async Ruby dispatcher
|
|
216
|
+
// (D1 writes, KV writes, fetch calls) can finish even though
|
|
217
|
+
// `scheduled` returns a Promise the runtime may not fully await
|
|
218
|
+
// on its own.
|
|
219
|
+
const work = (async () => {
|
|
220
|
+
try {
|
|
221
|
+
return await dispatch(event, env, ctx);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
try {
|
|
224
|
+
globalThis.console.error("[scheduled] dispatcher threw:", err && err.stack || err);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
// ignore
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
})();
|
|
230
|
+
ctx.waitUntil(work);
|
|
231
|
+
return work;
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
// Phase 11B — Cloudflare Queues consumer entry point.
|
|
235
|
+
//
|
|
236
|
+
// The Workers runtime invokes this `queue` export once per batch
|
|
237
|
+
// for every `[[queues.consumers]]` entry in wrangler.toml. The
|
|
238
|
+
// Ruby side registers handlers through the `consume_queue
|
|
239
|
+
// 'queue-name' do |batch| ... end` DSL (lib/sinatra/queue.rb);
|
|
240
|
+
// `globalThis.__HOMURA_QUEUE_DISPATCH__` walks `batch.queue`
|
|
241
|
+
// against those handlers and runs whichever matches. A bad handler
|
|
242
|
+
// doesn't crash the consumer — errors are caught and logged so
|
|
243
|
+
// sibling handlers still run.
|
|
244
|
+
async queue(batch, env, ctx) {
|
|
245
|
+
const dispatch = queueDispatch();
|
|
246
|
+
if (typeof dispatch !== "function") {
|
|
247
|
+
try {
|
|
248
|
+
globalThis.console.error(
|
|
249
|
+
"homura: queue dispatcher not installed (require 'cloudflare_workers/queue' missing)",
|
|
250
|
+
);
|
|
251
|
+
} catch (e) {}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const work = (async () => {
|
|
255
|
+
try {
|
|
256
|
+
return await dispatch(batch, env, ctx);
|
|
257
|
+
} catch (err) {
|
|
258
|
+
try {
|
|
259
|
+
globalThis.console.error("[queue] dispatcher threw:", err && err.stack || err);
|
|
260
|
+
} catch (e) {}
|
|
261
|
+
}
|
|
262
|
+
})();
|
|
263
|
+
ctx.waitUntil(work);
|
|
264
|
+
return work;
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------
|
|
269
|
+
// Phase 11B — Durable Objects entry point.
|
|
270
|
+
//
|
|
271
|
+
// Every DO class listed in wrangler.toml's `[[durable_objects.bindings]]`
|
|
272
|
+
// must be a named export on this module. We export ONE generic class
|
|
273
|
+
// (`HomuraCounterDO`) that forwards every `fetch(req)` it receives
|
|
274
|
+
// to the Ruby-side dispatcher installed by
|
|
275
|
+
// `lib/cloudflare_workers/durable_object.rb`
|
|
276
|
+
// (`globalThis.__HOMURA_DO_DISPATCH__`).
|
|
277
|
+
//
|
|
278
|
+
// The Ruby handler is registered via:
|
|
279
|
+
//
|
|
280
|
+
// Cloudflare::DurableObject.define('HomuraCounterDO') do |state, req|
|
|
281
|
+
// ...
|
|
282
|
+
// end
|
|
283
|
+
//
|
|
284
|
+
// Keep this JS class as minimal as possible — every piece of DO logic
|
|
285
|
+
// lives in Ruby. When a new DO class is needed in a later phase, add
|
|
286
|
+
// another exported class next to this one with the same dispatcher
|
|
287
|
+
// forwarding and a different `class_name` argument.
|
|
288
|
+
// ---------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
async function __homuraForwardDO(class_name, state, env, request) {
|
|
291
|
+
// Pre-await the request body so the Ruby dispatcher (which runs
|
|
292
|
+
// synchronously under Opal) can read it without its own await.
|
|
293
|
+
// Multipart uses the same byte-preserving path as Module Worker fetch (Phase 11A).
|
|
294
|
+
const bodyResult = await readBodyTextForRubyDispatcher(request);
|
|
295
|
+
if (bodyResult.bodyReadError) {
|
|
296
|
+
return bodyResult.bodyReadError;
|
|
297
|
+
}
|
|
298
|
+
const bodyText = bodyResult.bodyText;
|
|
299
|
+
const dispatch = durableObjectDispatch();
|
|
300
|
+
if (typeof dispatch !== "function") {
|
|
301
|
+
return new Response(
|
|
302
|
+
JSON.stringify({ error: "homura: DO dispatcher not installed" }),
|
|
303
|
+
{ status: 500, headers: { "content-type": "application/json" } },
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return await dispatch(class_name, state, env, request, bodyText);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export class HomuraCounterDO {
|
|
310
|
+
constructor(state, env) {
|
|
311
|
+
this.state = state;
|
|
312
|
+
this.env = env;
|
|
313
|
+
}
|
|
314
|
+
async fetch(request) {
|
|
315
|
+
// Upgrade path: if the request asks for a WebSocket, accept one
|
|
316
|
+
// end of a new pair via the Hibernation API and hand the other
|
|
317
|
+
// end back to the client as part of a 101 response. After this,
|
|
318
|
+
// the runtime dispatches any subsequent frames on the server
|
|
319
|
+
// socket through the `webSocketMessage` / `webSocketClose` /
|
|
320
|
+
// `webSocketError` methods below (which forward to Ruby).
|
|
321
|
+
if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") {
|
|
322
|
+
const pair = new WebSocketPair();
|
|
323
|
+
// The server end hibernates with the DO; tag it with the path
|
|
324
|
+
// so Ruby handlers can filter.
|
|
325
|
+
let url;
|
|
326
|
+
try { url = new URL(request.url); } catch (_e) { url = { pathname: "/" }; }
|
|
327
|
+
const tag = "path:" + (url.pathname || "/");
|
|
328
|
+
try {
|
|
329
|
+
this.state.acceptWebSocket(pair[1], [tag]);
|
|
330
|
+
} catch (_e) {
|
|
331
|
+
// Runtimes without Hibernation API — fall back to accepting
|
|
332
|
+
// manually AND attaching event listeners that forward frames
|
|
333
|
+
// to the same Ruby-side dispatchers `webSocketMessage` /
|
|
334
|
+
// `webSocketClose` / `webSocketError` use. Without these the
|
|
335
|
+
// upgrade would succeed but messages would silently drop
|
|
336
|
+
// (Copilot review PR #9, fourth pass).
|
|
337
|
+
try { pair[1].accept(); } catch (_) {}
|
|
338
|
+
const self = this;
|
|
339
|
+
pair[1].addEventListener("message", async (ev) => {
|
|
340
|
+
const fn = durableObjectWsMessage();
|
|
341
|
+
if (typeof fn === "function") {
|
|
342
|
+
try { await fn("HomuraCounterDO", pair[1], ev.data, self.state, self.env); } catch (_) {}
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
pair[1].addEventListener("close", async (ev) => {
|
|
346
|
+
const fn = durableObjectWsClose();
|
|
347
|
+
if (typeof fn === "function") {
|
|
348
|
+
try { await fn("HomuraCounterDO", pair[1], ev.code, ev.reason, ev.wasClean, self.state, self.env); } catch (_) {}
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
pair[1].addEventListener("error", async (ev) => {
|
|
352
|
+
const fn = durableObjectWsError();
|
|
353
|
+
if (typeof fn === "function") {
|
|
354
|
+
try { await fn("HomuraCounterDO", pair[1], ev, self.state, self.env); } catch (_) {}
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
359
|
+
}
|
|
360
|
+
return __homuraForwardDO("HomuraCounterDO", this.state, this.env, request);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Hibernation API callbacks — routed into Ruby via the hooks
|
|
364
|
+
// installed by `lib/cloudflare_workers/durable_object.rb`. Each
|
|
365
|
+
// hook is optional on the Ruby side; missing hooks are a no-op.
|
|
366
|
+
async webSocketMessage(ws, message) {
|
|
367
|
+
const fn = durableObjectWsMessage();
|
|
368
|
+
if (typeof fn === "function") {
|
|
369
|
+
return fn("HomuraCounterDO", ws, message, this.state, this.env);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async webSocketClose(ws, code, reason, wasClean) {
|
|
373
|
+
const fn = durableObjectWsClose();
|
|
374
|
+
if (typeof fn === "function") {
|
|
375
|
+
return fn("HomuraCounterDO", ws, code, reason, wasClean, this.state, this.env);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async webSocketError(ws, err) {
|
|
379
|
+
const fn = durableObjectWsError();
|
|
380
|
+
if (typeof fn === "function") {
|
|
381
|
+
return fn("HomuraCounterDO", ws, err, this.state, this.env);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Example wrangler fragment for apps using cloudflare-workers-runtime.
|
|
2
|
+
# Copy patterns into your own wrangler.toml and set:
|
|
3
|
+
# main = "gems/homura-runtime/runtime/worker.mjs"
|
|
4
|
+
# (adjust path if you vendor the gem elsewhere.)
|
|
5
|
+
#
|
|
6
|
+
# name = "my-opal-worker"
|
|
7
|
+
# main = "gems/homura-runtime/runtime/worker.mjs"
|
|
8
|
+
# compatibility_date = "2026-04-15"
|
|
9
|
+
# compatibility_flags = ["nodejs_compat"]
|
|
10
|
+
#
|
|
11
|
+
# --- Optional bindings (uncomment what you use) ---
|
|
12
|
+
#
|
|
13
|
+
# [[d1_databases]]
|
|
14
|
+
# binding = "DB"
|
|
15
|
+
# database_name = "my-db"
|
|
16
|
+
# database_id = "REPLACE_ME"
|
|
17
|
+
#
|
|
18
|
+
# [[kv_namespaces]]
|
|
19
|
+
# binding = "KV"
|
|
20
|
+
# id = "REPLACE_ME"
|
|
21
|
+
#
|
|
22
|
+
# [[r2_buckets]]
|
|
23
|
+
# binding = "BUCKET"
|
|
24
|
+
# bucket_name = "my-bucket"
|
|
25
|
+
#
|
|
26
|
+
# [ai]
|
|
27
|
+
# binding = "AI"
|
|
28
|
+
#
|
|
29
|
+
# [[durable_objects.bindings]]
|
|
30
|
+
# name = "MY_DO"
|
|
31
|
+
# class_name = "MyDurableObjectClass"
|
|
32
|
+
#
|
|
33
|
+
# [[queues.producers]]
|
|
34
|
+
# binding = "MY_QUEUE"
|
|
35
|
+
# queue = "my-queue"
|
|
36
|
+
#
|
|
37
|
+
# [[queues.consumers]]
|
|
38
|
+
# queue = "my-queue"
|
|
39
|
+
# max_batch_size = 10
|
|
40
|
+
# max_batch_timeout = 5
|
metadata
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: homura-runtime
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kazuhiro NISHIYAMA
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: opal-homura
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 1.8.3.rc1
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 1.8.3.rc1
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: parser
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '3.3'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.3'
|
|
40
|
+
description: |
|
|
41
|
+
Sinatra-free core for running Opal-compiled Ruby on Cloudflare Workers:
|
|
42
|
+
Rack handler, D1/KV/R2/AI/Queue/Durable Object adapters, multipart/streaming,
|
|
43
|
+
and Opal corelib patches. Use with the `opal` gem and a Module Worker
|
|
44
|
+
(`runtime/worker.mjs` in this gem).
|
|
45
|
+
executables:
|
|
46
|
+
- cloudflare-workers-build
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- CHANGELOG.md
|
|
51
|
+
- README.md
|
|
52
|
+
- bin/cloudflare-workers-build
|
|
53
|
+
- docs/ARCHITECTURE.md
|
|
54
|
+
- exe/auto-await
|
|
55
|
+
- exe/compile-assets
|
|
56
|
+
- exe/compile-erb
|
|
57
|
+
- lib/cloudflare_workers.rb
|
|
58
|
+
- lib/cloudflare_workers/ai.rb
|
|
59
|
+
- lib/cloudflare_workers/async_registry.rb
|
|
60
|
+
- lib/cloudflare_workers/auto_await/analyzer.rb
|
|
61
|
+
- lib/cloudflare_workers/auto_await/transformer.rb
|
|
62
|
+
- lib/cloudflare_workers/cache.rb
|
|
63
|
+
- lib/cloudflare_workers/durable_object.rb
|
|
64
|
+
- lib/cloudflare_workers/email.rb
|
|
65
|
+
- lib/cloudflare_workers/http.rb
|
|
66
|
+
- lib/cloudflare_workers/multipart.rb
|
|
67
|
+
- lib/cloudflare_workers/queue.rb
|
|
68
|
+
- lib/cloudflare_workers/scheduled.rb
|
|
69
|
+
- lib/cloudflare_workers/stream.rb
|
|
70
|
+
- lib/cloudflare_workers/version.rb
|
|
71
|
+
- lib/opal_patches.rb
|
|
72
|
+
- runtime/patch-opal-evals.mjs
|
|
73
|
+
- runtime/setup-node-crypto.mjs
|
|
74
|
+
- runtime/worker.mjs
|
|
75
|
+
- runtime/worker_module.mjs
|
|
76
|
+
- runtime/wrangler.toml.example
|
|
77
|
+
- templates/wrangler.toml.example
|
|
78
|
+
homepage: https://github.com/kazuph/homura
|
|
79
|
+
licenses:
|
|
80
|
+
- MIT
|
|
81
|
+
metadata:
|
|
82
|
+
homepage_uri: https://github.com/kazuph/homura
|
|
83
|
+
source_code_uri: https://github.com/kazuph/homura/tree/main/gems/homura-runtime
|
|
84
|
+
bug_tracker_uri: https://github.com/kazuph/homura/issues
|
|
85
|
+
changelog_uri: https://github.com/kazuph/homura/blob/main/gems/homura-runtime/CHANGELOG.md
|
|
86
|
+
readme_uri: https://github.com/kazuph/homura/blob/main/gems/homura-runtime/README.md
|
|
87
|
+
rdoc_options: []
|
|
88
|
+
require_paths:
|
|
89
|
+
- lib
|
|
90
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: 3.4.0
|
|
95
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
96
|
+
requirements:
|
|
97
|
+
- - ">="
|
|
98
|
+
- !ruby/object:Gem::Version
|
|
99
|
+
version: '0'
|
|
100
|
+
requirements: []
|
|
101
|
+
rubygems_version: 3.6.9
|
|
102
|
+
specification_version: 4
|
|
103
|
+
summary: Cloudflare Workers + Opal runtime core (Rack dispatch, bindings, patches)
|
|
104
|
+
test_files: []
|