@b9g/platform-cloudflare 0.1.15 → 0.1.16

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@b9g/platform-cloudflare",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Cloudflare Workers platform adapter for Shovel - already ServiceWorker-based!",
5
5
  "keywords": [
6
6
  "shovel",
@@ -12,16 +12,16 @@
12
12
  ],
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "https://github.com/bikeshaving/shovel.git",
15
+ "url": "git+https://github.com/bikeshaving/shovel.git",
16
16
  "directory": "packages/platform-cloudflare"
17
17
  },
18
18
  "dependencies": {
19
19
  "@b9g/assets": "^0.2.1",
20
20
  "@b9g/async-context": "^0.2.1",
21
21
  "@b9g/cache": "^0.2.2",
22
- "@b9g/filesystem": "^0.1.10",
23
- "@b9g/platform": "^0.1.17",
24
- "@logtape/logtape": "^1.2.0",
22
+ "@b9g/filesystem": "^0.2.0",
23
+ "@b9g/platform": "^0.1.18",
24
+ "@logtape/logtape": "^2.0.0",
25
25
  "mime": "^4.0.4",
26
26
  "miniflare": "^4.20251118.1"
27
27
  },
@@ -84,6 +84,15 @@
84
84
  "./platform.js": {
85
85
  "types": "./src/platform.d.ts",
86
86
  "import": "./src/platform.js"
87
- }
87
+ },
88
+ "./pubsub": {
89
+ "types": "./src/pubsub.d.ts",
90
+ "import": "./src/pubsub.js"
91
+ },
92
+ "./pubsub.js": {
93
+ "types": "./src/pubsub.d.ts",
94
+ "import": "./src/pubsub.js"
95
+ },
96
+ "./cloudflare.d.ts": "./src/cloudflare.d.ts"
88
97
  }
89
98
  }
package/src/caches.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /// <reference path="./cloudflare.d.ts" />
1
2
  /**
2
3
  * Cloudflare Native Cache
3
4
  *
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Minimal Cloudflare Workers type declarations.
3
+ *
4
+ * We declare only the types this package needs rather than using
5
+ * @cloudflare/workers-types globally, which would pollute Request/Response
6
+ * with Cloudflare-specific generics across the entire monorepo.
7
+ */
8
+
9
+ // cloudflare:workers module (runtime imports)
10
+ declare module "cloudflare:workers" {
11
+ export abstract class DurableObject {
12
+ ctx: DurableObjectState;
13
+ env: Record<string, unknown>;
14
+ constructor(ctx: DurableObjectState, env: Record<string, unknown>);
15
+ fetch?(request: Request): Promise<Response>;
16
+ alarm?(): Promise<void>;
17
+ webSocketMessage?(
18
+ ws: WebSocket,
19
+ message: string | ArrayBuffer,
20
+ ): Promise<void>;
21
+ webSocketClose?(
22
+ ws: WebSocket,
23
+ code: number,
24
+ reason: string,
25
+ wasClean: boolean,
26
+ ): Promise<void>;
27
+ webSocketError?(ws: WebSocket, error: unknown): Promise<void>;
28
+ }
29
+ }
30
+
31
+ // Cloudflare-specific globals available in the Workers runtime
32
+ declare class DurableObjectState {
33
+ id: DurableObjectId;
34
+ storage: DurableObjectStorage;
35
+ acceptWebSocket(ws: WebSocket, tags?: string[]): void;
36
+ getWebSockets(tag?: string): WebSocket[];
37
+ waitUntil(promise: Promise<unknown>): void;
38
+ }
39
+
40
+ declare class DurableObjectStorage {
41
+ get<T = unknown>(key: string): Promise<T | undefined>;
42
+ get<T = unknown>(keys: string[]): Promise<Map<string, T>>;
43
+ put<T>(key: string, value: T): Promise<void>;
44
+ put<T>(entries: Record<string, T>): Promise<void>;
45
+ delete(key: string): Promise<boolean>;
46
+ delete(keys: string[]): Promise<number>;
47
+ list<T = unknown>(options?: {
48
+ prefix?: string;
49
+ limit?: number;
50
+ }): Promise<Map<string, T>>;
51
+ }
52
+
53
+ declare class DurableObjectId {
54
+ toString(): string;
55
+ readonly name?: string;
56
+ }
57
+
58
+ declare interface DurableObjectNamespace {
59
+ newUniqueId(): DurableObjectId;
60
+ idFromName(name: string): DurableObjectId;
61
+ idFromString(id: string): DurableObjectId;
62
+ get(id: DurableObjectId): DurableObjectStub;
63
+ }
64
+
65
+ declare interface DurableObjectStub {
66
+ readonly id: DurableObjectId;
67
+ readonly name?: string;
68
+ fetch(requestOrUrl: string | Request, init?: RequestInit): Promise<Response>;
69
+ }
70
+
71
+ declare var WebSocketPair: {
72
+ new (): {0: WebSocket; 1: WebSocket};
73
+ };
74
+
75
+ // Augment Response constructor options for Cloudflare's webSocket field
76
+ declare interface ResponseInit {
77
+ webSocket?: WebSocket;
78
+ }
79
+
80
+ // Augment WebSocket with Cloudflare's accept() method
81
+ interface WebSocket {
82
+ accept(): void;
83
+ }
@@ -1,3 +1,4 @@
1
+ /// <reference path="./cloudflare.d.ts" />
1
2
  /**
2
3
  * Cloudflare Directory Implementations
3
4
  *
package/src/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /// <reference path="./cloudflare.d.ts" />
1
2
  /**
2
3
  * @b9g/platform-cloudflare - Cloudflare Workers platform adapter for Shovel
3
4
  *
package/src/index.js CHANGED
@@ -252,6 +252,7 @@ export default { fetch: createFetchHandler(registration) };
252
252
  // Include both node:* prefix and bare module names for compatibility
253
253
  external: [
254
254
  "node:*",
255
+ "cloudflare:*",
255
256
  "path",
256
257
  "fs",
257
258
  "fs/promises",
package/src/platform.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /// <reference path="./cloudflare.d.ts" />
1
2
  /**
2
3
  * Cloudflare Platform Module
3
4
  *
package/src/platform.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /// <reference types="./platform.d.ts" />
2
2
  // src/platform.ts
3
3
  import { getLogger } from "@logtape/logtape";
4
+ import { dirname, join } from "path";
4
5
  var logger = getLogger(["shovel", "platform"]);
5
6
  var name = "cloudflare";
6
7
  function getEntryPoints(userEntryPath, _mode) {
@@ -26,6 +27,7 @@ function getESBuildConfig() {
26
27
  conditions: ["worker", "browser"],
27
28
  external: [
28
29
  "node:*",
30
+ "cloudflare:*",
29
31
  "path",
30
32
  "fs",
31
33
  "fs/promises",
@@ -57,14 +59,20 @@ async function createDevServer(options) {
57
59
  const { port, host, workerPath } = options;
58
60
  logger.info("Starting Miniflare dev server", { workerPath });
59
61
  const { Miniflare } = await import("miniflare");
60
- let miniflare = new Miniflare({
62
+ const publicDir = join(dirname(dirname(workerPath)), "public");
63
+ const getMiniflareOptions = (scriptPath) => ({
61
64
  modules: true,
62
- scriptPath: workerPath,
65
+ scriptPath,
63
66
  compatibilityDate: "2024-09-23",
64
67
  compatibilityFlags: ["nodejs_compat"],
65
68
  port,
66
- host
69
+ host,
70
+ assets: {
71
+ directory: publicDir,
72
+ binding: "ASSETS"
73
+ }
67
74
  });
75
+ let miniflare = new Miniflare(getMiniflareOptions(workerPath));
68
76
  await miniflare.ready;
69
77
  logger.info("Miniflare dev server ready");
70
78
  const url = `http://${host}:${port}`;
@@ -73,14 +81,7 @@ async function createDevServer(options) {
73
81
  async reload(newWorkerPath) {
74
82
  logger.info("Reloading Miniflare", { workerPath: newWorkerPath });
75
83
  await miniflare.dispose();
76
- miniflare = new Miniflare({
77
- modules: true,
78
- scriptPath: newWorkerPath,
79
- compatibilityDate: "2024-09-23",
80
- compatibilityFlags: ["nodejs_compat"],
81
- port,
82
- host
83
- });
84
+ miniflare = new Miniflare(getMiniflareOptions(newWorkerPath));
84
85
  await miniflare.ready;
85
86
  logger.info("Miniflare reloaded");
86
87
  },
@@ -0,0 +1,46 @@
1
+ /// <reference path="./cloudflare.d.ts" />
2
+ /**
3
+ * Cloudflare Durable Object PubSub Backend
4
+ *
5
+ * Provides cross-isolate BroadcastChannel relay via a Durable Object.
6
+ * - CloudflarePubSubBackend: BroadcastChannelBackend that publishes to a DO
7
+ * - ShovelPubSubDO: Durable Object that broadcasts to connected WebSocket clients
8
+ *
9
+ * Opt-in: only active when env.SHOVEL_PUBSUB binding is present.
10
+ *
11
+ * Architecture:
12
+ * - publish() sends a POST to the DO with {channel, data, sender}
13
+ * - subscribe() opens a WebSocket to the DO to receive broadcasts
14
+ * - The DO fans out POST payloads to all connected WebSocket clients
15
+ * - Sender filtering happens client-side (same pattern as Redis backend)
16
+ *
17
+ * Note: Cloudflare Workers are ephemeral, so WebSocket subscriptions only
18
+ * live as long as the Worker's execution context. For typical request/response
19
+ * Workers this means subscriptions are short-lived. For Durable Object contexts
20
+ * or Workers using waitUntil(), subscriptions can persist longer.
21
+ */
22
+ import { DurableObject } from "cloudflare:workers";
23
+ import type { BroadcastChannelBackend } from "@b9g/platform/runtime";
24
+ /**
25
+ * BroadcastChannel backend that routes messages through a Durable Object.
26
+ *
27
+ * Uses an instance ID to filter out own messages (prevents echo),
28
+ * matching the pattern used by RedisPubSubBackend.
29
+ */
30
+ export declare class CloudflarePubSubBackend implements BroadcastChannelBackend {
31
+ #private;
32
+ constructor(ns: DurableObjectNamespace);
33
+ publish(channelName: string, data: unknown): void;
34
+ subscribe(channelName: string, callback: (data: unknown) => void): () => void;
35
+ dispose(): Promise<void>;
36
+ }
37
+ /**
38
+ * Durable Object that fans out BroadcastChannel messages to connected
39
+ * WebSocket clients. Uses WebSocket Hibernation API for efficiency.
40
+ */
41
+ export declare class ShovelPubSubDO extends DurableObject {
42
+ fetch(request: Request): Promise<Response>;
43
+ webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void>;
44
+ webSocketClose(_ws: WebSocket, _code: number, _reason: string, _wasClean: boolean): Promise<void>;
45
+ webSocketError(_ws: WebSocket, _error: unknown): Promise<void>;
46
+ }
package/src/pubsub.js ADDED
@@ -0,0 +1,128 @@
1
+ /// <reference types="./pubsub.d.ts" />
2
+ // src/pubsub.ts
3
+ import { DurableObject } from "cloudflare:workers";
4
+ import { getLogger } from "@logtape/logtape";
5
+ var logger = getLogger(["shovel", "pubsub"]);
6
+ var CloudflarePubSubBackend = class {
7
+ #ns;
8
+ #instanceId;
9
+ #ws;
10
+ #wsReady;
11
+ #callbacks;
12
+ constructor(ns) {
13
+ this.#ns = ns;
14
+ this.#instanceId = crypto.randomUUID();
15
+ this.#ws = null;
16
+ this.#wsReady = null;
17
+ this.#callbacks = /* @__PURE__ */ new Map();
18
+ }
19
+ #ensureConnection() {
20
+ if (this.#wsReady)
21
+ return;
22
+ this.#wsReady = this.#connect().catch((err) => {
23
+ logger.error("PubSub WebSocket connection failed: {error}", {
24
+ error: err
25
+ });
26
+ this.#wsReady = null;
27
+ });
28
+ }
29
+ async #connect() {
30
+ const id = this.#ns.idFromName("pubsub");
31
+ const stub = this.#ns.get(id);
32
+ const response = await stub.fetch("http://internal/subscribe", {
33
+ headers: { Upgrade: "websocket" }
34
+ });
35
+ const ws = response.webSocket;
36
+ if (!ws) {
37
+ throw new Error("WebSocket upgrade to PubSub DO failed");
38
+ }
39
+ ws.accept();
40
+ this.#ws = ws;
41
+ ws.addEventListener("message", (ev) => {
42
+ try {
43
+ const { channel, data, sender } = JSON.parse(ev.data);
44
+ if (sender === this.#instanceId)
45
+ return;
46
+ const cbs = this.#callbacks.get(channel);
47
+ if (cbs) {
48
+ for (const cb of cbs)
49
+ cb(data);
50
+ }
51
+ } catch (err) {
52
+ logger.debug("Failed to parse pubsub message: {error}", {
53
+ error: err
54
+ });
55
+ }
56
+ });
57
+ }
58
+ publish(channelName, data) {
59
+ const id = this.#ns.idFromName("pubsub");
60
+ const stub = this.#ns.get(id);
61
+ stub.fetch("http://internal/broadcast", {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify({
65
+ channel: channelName,
66
+ data,
67
+ sender: this.#instanceId
68
+ })
69
+ }).catch((err) => {
70
+ logger.error("PubSub publish failed: {error}", { error: err });
71
+ });
72
+ }
73
+ subscribe(channelName, callback) {
74
+ this.#ensureConnection();
75
+ let cbs = this.#callbacks.get(channelName);
76
+ if (!cbs) {
77
+ cbs = /* @__PURE__ */ new Set();
78
+ this.#callbacks.set(channelName, cbs);
79
+ }
80
+ cbs.add(callback);
81
+ return () => {
82
+ cbs.delete(callback);
83
+ if (cbs.size === 0)
84
+ this.#callbacks.delete(channelName);
85
+ };
86
+ }
87
+ async dispose() {
88
+ this.#ws?.close();
89
+ this.#ws = null;
90
+ this.#wsReady = null;
91
+ this.#callbacks.clear();
92
+ }
93
+ };
94
+ var ShovelPubSubDO = class extends DurableObject {
95
+ async fetch(request) {
96
+ const url = new URL(request.url);
97
+ if (request.headers.get("Upgrade") === "websocket") {
98
+ const pair = new WebSocketPair();
99
+ const [client, server] = Object.values(pair);
100
+ this.ctx.acceptWebSocket(server);
101
+ return new Response(null, { status: 101, webSocket: client });
102
+ }
103
+ if (request.method === "POST" && url.pathname === "/broadcast") {
104
+ const payload = await request.text();
105
+ for (const ws of this.ctx.getWebSockets()) {
106
+ ws.send(payload);
107
+ }
108
+ return new Response("OK");
109
+ }
110
+ return new Response("Not Found", { status: 404 });
111
+ }
112
+ async webSocketMessage(ws, message) {
113
+ const data = typeof message === "string" ? message : message;
114
+ for (const peer of this.ctx.getWebSockets()) {
115
+ if (peer !== ws) {
116
+ peer.send(data);
117
+ }
118
+ }
119
+ }
120
+ async webSocketClose(_ws, _code, _reason, _wasClean) {
121
+ }
122
+ async webSocketError(_ws, _error) {
123
+ }
124
+ };
125
+ export {
126
+ CloudflarePubSubBackend,
127
+ ShovelPubSubDO
128
+ };
package/src/runtime.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ /// <reference path="./cloudflare.d.ts" />
1
2
  /**
2
3
  * Cloudflare Worker Runtime
3
4
  *
package/src/runtime.js CHANGED
@@ -9,7 +9,8 @@ import {
9
9
  createCacheFactory,
10
10
  createDirectoryFactory,
11
11
  runLifecycle,
12
- dispatchRequest
12
+ dispatchRequest,
13
+ setBroadcastChannelBackend
13
14
  } from "@b9g/platform/runtime";
14
15
  import { CustomCacheStorage } from "@b9g/cache";
15
16
  import { CustomDirectoryStorage } from "@b9g/filesystem";
@@ -50,17 +51,28 @@ async function initializeRuntime(config) {
50
51
  }
51
52
  function createFetchHandler(registration) {
52
53
  let lifecyclePromise = null;
54
+ let bcBackendConfigured = false;
53
55
  return async (request, env, ctx) => {
54
56
  if (!lifecyclePromise) {
55
57
  lifecyclePromise = runLifecycle(registration, "activate");
56
58
  }
57
59
  await lifecyclePromise;
60
+ const envRecord = env;
61
+ if (!bcBackendConfigured && envRecord.SHOVEL_PUBSUB) {
62
+ const { CloudflarePubSubBackend } = await import("./pubsub.js");
63
+ setBroadcastChannelBackend(
64
+ new CloudflarePubSubBackend(
65
+ envRecord.SHOVEL_PUBSUB
66
+ )
67
+ );
68
+ bcBackendConfigured = true;
69
+ }
58
70
  const event = new CloudflareFetchEvent(request, {
59
- env,
71
+ env: envRecord,
60
72
  platformWaitUntil: (promise) => ctx.waitUntil(promise)
61
73
  });
62
74
  return envStorage.run(
63
- env,
75
+ envRecord,
64
76
  () => dispatchRequest(registration, event)
65
77
  );
66
78
  };
@@ -1,3 +1,4 @@
1
+ /// <reference path="./cloudflare.d.ts" />
1
2
  /**
2
3
  * Cloudflare Environment Storage
3
4
  *