@b9g/platform-cloudflare 0.1.15 → 0.1.17
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 +15 -6
- package/src/caches.d.ts +1 -0
- package/src/cloudflare.d.ts +83 -0
- package/src/directories.d.ts +1 -0
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/platform.d.ts +1 -0
- package/src/platform.js +13 -11
- package/src/pubsub.d.ts +46 -0
- package/src/pubsub.js +128 -0
- package/src/runtime.d.ts +1 -0
- package/src/runtime.js +15 -3
- package/src/variables.d.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@b9g/platform-cloudflare",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.17",
|
|
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.
|
|
23
|
-
"@b9g/platform": "^0.1.
|
|
24
|
-
"@logtape/logtape": "^
|
|
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
|
@@ -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
|
+
}
|
package/src/directories.d.ts
CHANGED
package/src/index.d.ts
CHANGED
package/src/index.js
CHANGED
package/src/platform.d.ts
CHANGED
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,21 @@ 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
|
-
|
|
62
|
+
const publicDir = join(dirname(dirname(workerPath)), "public");
|
|
63
|
+
const getMiniflareOptions = (scriptPath) => ({
|
|
61
64
|
modules: true,
|
|
62
|
-
scriptPath
|
|
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
|
+
routerConfig: { has_user_worker: true }
|
|
74
|
+
}
|
|
67
75
|
});
|
|
76
|
+
let miniflare = new Miniflare(getMiniflareOptions(workerPath));
|
|
68
77
|
await miniflare.ready;
|
|
69
78
|
logger.info("Miniflare dev server ready");
|
|
70
79
|
const url = `http://${host}:${port}`;
|
|
@@ -73,14 +82,7 @@ async function createDevServer(options) {
|
|
|
73
82
|
async reload(newWorkerPath) {
|
|
74
83
|
logger.info("Reloading Miniflare", { workerPath: newWorkerPath });
|
|
75
84
|
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
|
-
});
|
|
85
|
+
miniflare = new Miniflare(getMiniflareOptions(newWorkerPath));
|
|
84
86
|
await miniflare.ready;
|
|
85
87
|
logger.info("Miniflare reloaded");
|
|
86
88
|
},
|
package/src/pubsub.d.ts
ADDED
|
@@ -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
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
|
-
|
|
75
|
+
envRecord,
|
|
64
76
|
() => dispatchRequest(registration, event)
|
|
65
77
|
);
|
|
66
78
|
};
|
package/src/variables.d.ts
CHANGED