@b9g/platform-cloudflare 0.1.8 → 0.1.10
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 +21 -5
- package/src/cloudflare-runtime.d.ts +39 -0
- package/src/cloudflare-runtime.js +102 -0
- package/src/env.d.ts +9 -0
- package/src/filesystem-r2.d.ts +91 -0
- package/src/filesystem-r2.js +230 -0
- package/src/index.d.ts +63 -33
- package/src/index.js +181 -274
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@b9g/platform-cloudflare",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Cloudflare Workers platform adapter for Shovel - already ServiceWorker-based!",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"shovel",
|
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
"serviceworker"
|
|
12
12
|
],
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@b9g/assets": "^0.1.
|
|
15
|
-
"@b9g/cache": "^0.1.
|
|
16
|
-
"@b9g/platform": "^0.1.
|
|
17
|
-
"@cloudflare/workers-types": "^4.20241218.0",
|
|
14
|
+
"@b9g/assets": "^0.1.15",
|
|
15
|
+
"@b9g/cache": "^0.1.5",
|
|
16
|
+
"@b9g/platform": "^0.1.12",
|
|
18
17
|
"@logtape/logtape": "^1.2.0",
|
|
18
|
+
"mime": "^4.0.4",
|
|
19
19
|
"miniflare": "^4.20251118.1"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
@@ -46,6 +46,22 @@
|
|
|
46
46
|
"./filesystem-assets.js": {
|
|
47
47
|
"types": "./src/filesystem-assets.d.ts",
|
|
48
48
|
"import": "./src/filesystem-assets.js"
|
|
49
|
+
},
|
|
50
|
+
"./cloudflare-runtime": {
|
|
51
|
+
"types": "./src/cloudflare-runtime.d.ts",
|
|
52
|
+
"import": "./src/cloudflare-runtime.js"
|
|
53
|
+
},
|
|
54
|
+
"./cloudflare-runtime.js": {
|
|
55
|
+
"types": "./src/cloudflare-runtime.d.ts",
|
|
56
|
+
"import": "./src/cloudflare-runtime.js"
|
|
57
|
+
},
|
|
58
|
+
"./filesystem-r2": {
|
|
59
|
+
"types": "./src/filesystem-r2.d.ts",
|
|
60
|
+
"import": "./src/filesystem-r2.js"
|
|
61
|
+
},
|
|
62
|
+
"./filesystem-r2.js": {
|
|
63
|
+
"types": "./src/filesystem-r2.d.ts",
|
|
64
|
+
"import": "./src/filesystem-r2.js"
|
|
49
65
|
}
|
|
50
66
|
}
|
|
51
67
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Worker Runtime - Browser-safe ServiceWorkerGlobals setup
|
|
3
|
+
*
|
|
4
|
+
* This module is BROWSER-SAFE and can be bundled into Cloudflare Workers.
|
|
5
|
+
* It only imports from browser-compatible modules:
|
|
6
|
+
* - @b9g/platform/runtime (no fs/path)
|
|
7
|
+
* - @b9g/filesystem (no fs/path in the index)
|
|
8
|
+
* - @b9g/async-context (browser-safe)
|
|
9
|
+
*
|
|
10
|
+
* DO NOT import from @b9g/platform (the index) - it pulls in Node-only code.
|
|
11
|
+
*/
|
|
12
|
+
import { ShovelServiceWorkerRegistration } from "@b9g/platform/runtime";
|
|
13
|
+
/**
|
|
14
|
+
* Cloudflare's ExecutionContext - passed to each request handler
|
|
15
|
+
* Used for ctx.waitUntil() to extend request lifetime
|
|
16
|
+
*/
|
|
17
|
+
export interface ExecutionContext {
|
|
18
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
19
|
+
passThroughOnException(): void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Get the current request's Cloudflare env object
|
|
23
|
+
* Contains all bindings: KV namespaces, R2 buckets, D1 databases, etc.
|
|
24
|
+
*/
|
|
25
|
+
export declare function getEnv<T = Record<string, unknown>>(): T | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* Get the current request's Cloudflare ExecutionContext
|
|
28
|
+
* Used for ctx.waitUntil() and other lifecycle methods
|
|
29
|
+
*/
|
|
30
|
+
export declare function getCtx(): ExecutionContext | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Initialize the Cloudflare runtime with ServiceWorkerGlobals
|
|
33
|
+
* Called once when the worker module loads (before user code runs)
|
|
34
|
+
*/
|
|
35
|
+
export declare function initializeRuntime(): ShovelServiceWorkerRegistration;
|
|
36
|
+
/**
|
|
37
|
+
* Create the ES module fetch handler for Cloudflare Workers
|
|
38
|
+
*/
|
|
39
|
+
export declare function createFetchHandler(registration: ShovelServiceWorkerRegistration): (request: Request, env: unknown, ctx: ExecutionContext) => Promise<Response>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/// <reference types="./cloudflare-runtime.d.ts" />
|
|
2
|
+
// src/cloudflare-runtime.ts
|
|
3
|
+
import {
|
|
4
|
+
ServiceWorkerGlobals,
|
|
5
|
+
ShovelServiceWorkerRegistration,
|
|
6
|
+
CustomLoggerStorage
|
|
7
|
+
} from "@b9g/platform/runtime";
|
|
8
|
+
import { CustomDirectoryStorage } from "@b9g/filesystem";
|
|
9
|
+
import { AsyncContext } from "@b9g/async-context";
|
|
10
|
+
import { getLogger } from "@logtape/logtape";
|
|
11
|
+
import { R2FileSystemDirectoryHandle } from "./filesystem-r2.js";
|
|
12
|
+
var envStorage = new AsyncContext.Variable();
|
|
13
|
+
var ctxStorage = new AsyncContext.Variable();
|
|
14
|
+
function getEnv() {
|
|
15
|
+
return envStorage.get();
|
|
16
|
+
}
|
|
17
|
+
function getCtx() {
|
|
18
|
+
return ctxStorage.get();
|
|
19
|
+
}
|
|
20
|
+
var _registration = null;
|
|
21
|
+
var _globals = null;
|
|
22
|
+
function initializeRuntime() {
|
|
23
|
+
if (_registration) {
|
|
24
|
+
return _registration;
|
|
25
|
+
}
|
|
26
|
+
_registration = new ShovelServiceWorkerRegistration();
|
|
27
|
+
const directories = new CustomDirectoryStorage(
|
|
28
|
+
createCloudflareR2DirectoryFactory()
|
|
29
|
+
);
|
|
30
|
+
_globals = new ServiceWorkerGlobals({
|
|
31
|
+
registration: _registration,
|
|
32
|
+
caches: globalThis.caches,
|
|
33
|
+
// Cloudflare's native Cache API
|
|
34
|
+
directories,
|
|
35
|
+
loggers: new CustomLoggerStorage((...cats) => getLogger(cats))
|
|
36
|
+
});
|
|
37
|
+
_globals.install();
|
|
38
|
+
return _registration;
|
|
39
|
+
}
|
|
40
|
+
function createFetchHandler(registration) {
|
|
41
|
+
return async (request, env, ctx) => {
|
|
42
|
+
return envStorage.run(
|
|
43
|
+
env,
|
|
44
|
+
() => ctxStorage.run(ctx, async () => {
|
|
45
|
+
try {
|
|
46
|
+
return await registration.handleRequest(request);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error("ServiceWorker error:", error);
|
|
49
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
50
|
+
const isDev = typeof import.meta !== "undefined" && import.meta.env?.MODE !== "production";
|
|
51
|
+
if (isDev) {
|
|
52
|
+
return new Response(
|
|
53
|
+
`<!DOCTYPE html>
|
|
54
|
+
<html>
|
|
55
|
+
<head><title>500 Internal Server Error</title>
|
|
56
|
+
<style>body{font-family:system-ui;padding:2rem;max-width:800px;margin:0 auto}h1{color:#c00}pre{background:#f5f5f5;padding:1rem;overflow-x:auto}</style>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<h1>500 Internal Server Error</h1>
|
|
60
|
+
<p>${escapeHtml(err.message)}</p>
|
|
61
|
+
<pre>${escapeHtml(err.stack || "No stack trace")}</pre>
|
|
62
|
+
</body></html>`,
|
|
63
|
+
{ status: 500, headers: { "Content-Type": "text/html" } }
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function escapeHtml(str) {
|
|
73
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
74
|
+
}
|
|
75
|
+
function createCloudflareR2DirectoryFactory() {
|
|
76
|
+
return async (name) => {
|
|
77
|
+
const env = getEnv();
|
|
78
|
+
if (!env) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Cannot access directory "${name}": Cloudflare env not available. Are you accessing directories outside of a request context?`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const bindingName = `${name.toUpperCase()}_R2`;
|
|
84
|
+
const r2Bucket = env[bindingName];
|
|
85
|
+
if (!r2Bucket) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`R2 bucket binding "${bindingName}" not found. Configure in wrangler.toml:
|
|
88
|
+
|
|
89
|
+
[[r2_buckets]]
|
|
90
|
+
binding = "${bindingName}"
|
|
91
|
+
bucket_name = "your-bucket-name"`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return new R2FileSystemDirectoryHandle(r2Bucket, "");
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export {
|
|
98
|
+
createFetchHandler,
|
|
99
|
+
getCtx,
|
|
100
|
+
getEnv,
|
|
101
|
+
initializeRuntime
|
|
102
|
+
};
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare R2 implementation of File System Access API
|
|
3
|
+
*
|
|
4
|
+
* Implements FileSystemDirectoryHandle and FileSystemFileHandle using Cloudflare R2 bindings
|
|
5
|
+
* to provide R2 cloud storage with File System Access API compatibility.
|
|
6
|
+
*/
|
|
7
|
+
import type { FileSystemConfig } from "@b9g/filesystem";
|
|
8
|
+
/** R2 object metadata */
|
|
9
|
+
export interface R2Object {
|
|
10
|
+
key: string;
|
|
11
|
+
uploaded: Date;
|
|
12
|
+
httpMetadata?: {
|
|
13
|
+
contentType?: string;
|
|
14
|
+
};
|
|
15
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
16
|
+
}
|
|
17
|
+
/** R2 list result */
|
|
18
|
+
export interface R2Objects {
|
|
19
|
+
objects: Array<{
|
|
20
|
+
key: string;
|
|
21
|
+
}>;
|
|
22
|
+
delimitedPrefixes: string[];
|
|
23
|
+
}
|
|
24
|
+
/** R2 bucket interface */
|
|
25
|
+
export interface R2Bucket {
|
|
26
|
+
get(key: string): Promise<R2Object | null>;
|
|
27
|
+
head(key: string): Promise<R2Object | null>;
|
|
28
|
+
put(key: string, value: ArrayBuffer | Uint8Array): Promise<R2Object>;
|
|
29
|
+
delete(key: string): Promise<void>;
|
|
30
|
+
list(options?: {
|
|
31
|
+
prefix?: string;
|
|
32
|
+
delimiter?: string;
|
|
33
|
+
}): Promise<R2Objects>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Cloudflare R2 implementation of FileSystemWritableFileStream
|
|
37
|
+
*/
|
|
38
|
+
export declare class R2FileSystemWritableFileStream extends WritableStream<Uint8Array> {
|
|
39
|
+
#private;
|
|
40
|
+
constructor(r2Bucket: R2Bucket, key: string);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Cloudflare R2 implementation of FileSystemFileHandle
|
|
44
|
+
*/
|
|
45
|
+
export declare class R2FileSystemFileHandle implements FileSystemFileHandle {
|
|
46
|
+
#private;
|
|
47
|
+
readonly kind: "file";
|
|
48
|
+
readonly name: string;
|
|
49
|
+
constructor(r2Bucket: R2Bucket, key: string);
|
|
50
|
+
getFile(): Promise<File>;
|
|
51
|
+
createWritable(): Promise<FileSystemWritableFileStream>;
|
|
52
|
+
createSyncAccessHandle(): Promise<FileSystemSyncAccessHandle>;
|
|
53
|
+
isSameEntry(other: FileSystemHandle): Promise<boolean>;
|
|
54
|
+
queryPermission(): Promise<PermissionState>;
|
|
55
|
+
requestPermission(): Promise<PermissionState>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Cloudflare R2 implementation of FileSystemDirectoryHandle
|
|
59
|
+
*/
|
|
60
|
+
export declare class R2FileSystemDirectoryHandle implements FileSystemDirectoryHandle {
|
|
61
|
+
#private;
|
|
62
|
+
readonly kind: "directory";
|
|
63
|
+
readonly name: string;
|
|
64
|
+
constructor(r2Bucket: R2Bucket, prefix: string);
|
|
65
|
+
getFileHandle(name: string, options?: {
|
|
66
|
+
create?: boolean;
|
|
67
|
+
}): Promise<FileSystemFileHandle>;
|
|
68
|
+
getDirectoryHandle(name: string, options?: {
|
|
69
|
+
create?: boolean;
|
|
70
|
+
}): Promise<FileSystemDirectoryHandle>;
|
|
71
|
+
removeEntry(name: string, options?: {
|
|
72
|
+
recursive?: boolean;
|
|
73
|
+
}): Promise<void>;
|
|
74
|
+
resolve(_possibleDescendant: FileSystemHandle): Promise<string[] | null>;
|
|
75
|
+
entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
|
|
76
|
+
keys(): AsyncIterableIterator<string>;
|
|
77
|
+
values(): AsyncIterableIterator<FileSystemHandle>;
|
|
78
|
+
isSameEntry(other: FileSystemHandle): Promise<boolean>;
|
|
79
|
+
queryPermission(): Promise<PermissionState>;
|
|
80
|
+
requestPermission(): Promise<PermissionState>;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* R2 filesystem adapter
|
|
84
|
+
*/
|
|
85
|
+
export declare class R2FileSystemAdapter {
|
|
86
|
+
#private;
|
|
87
|
+
constructor(r2Bucket: R2Bucket, config?: FileSystemConfig);
|
|
88
|
+
getFileSystemRoot(name?: string): Promise<FileSystemDirectoryHandle>;
|
|
89
|
+
getConfig(): FileSystemConfig;
|
|
90
|
+
dispose(): Promise<void>;
|
|
91
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/// <reference types="./filesystem-r2.d.ts" />
|
|
2
|
+
// src/filesystem-r2.ts
|
|
3
|
+
import mime from "mime";
|
|
4
|
+
var R2FileSystemWritableFileStream = class extends WritableStream {
|
|
5
|
+
#chunks;
|
|
6
|
+
#r2Bucket;
|
|
7
|
+
#key;
|
|
8
|
+
constructor(r2Bucket, key) {
|
|
9
|
+
const chunks = [];
|
|
10
|
+
super({
|
|
11
|
+
write: (chunk) => {
|
|
12
|
+
chunks.push(chunk);
|
|
13
|
+
return Promise.resolve();
|
|
14
|
+
},
|
|
15
|
+
close: async () => {
|
|
16
|
+
const totalLength = chunks.reduce(
|
|
17
|
+
(sum, chunk) => sum + chunk.length,
|
|
18
|
+
0
|
|
19
|
+
);
|
|
20
|
+
const buffer = new Uint8Array(totalLength);
|
|
21
|
+
let offset = 0;
|
|
22
|
+
for (const chunk of chunks) {
|
|
23
|
+
buffer.set(chunk, offset);
|
|
24
|
+
offset += chunk.length;
|
|
25
|
+
}
|
|
26
|
+
await r2Bucket.put(key, buffer);
|
|
27
|
+
},
|
|
28
|
+
abort: async () => {
|
|
29
|
+
chunks.length = 0;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
this.#chunks = chunks;
|
|
33
|
+
this.#r2Bucket = r2Bucket;
|
|
34
|
+
this.#key = key;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
var R2FileSystemFileHandle = class _R2FileSystemFileHandle {
|
|
38
|
+
kind;
|
|
39
|
+
name;
|
|
40
|
+
#r2Bucket;
|
|
41
|
+
#key;
|
|
42
|
+
constructor(r2Bucket, key) {
|
|
43
|
+
this.kind = "file";
|
|
44
|
+
this.#r2Bucket = r2Bucket;
|
|
45
|
+
this.#key = key;
|
|
46
|
+
this.name = key.split("/").pop() || key;
|
|
47
|
+
}
|
|
48
|
+
async getFile() {
|
|
49
|
+
const r2Object = await this.#r2Bucket.get(this.#key);
|
|
50
|
+
if (!r2Object) {
|
|
51
|
+
throw new DOMException("File not found", "NotFoundError");
|
|
52
|
+
}
|
|
53
|
+
const arrayBuffer = await r2Object.arrayBuffer();
|
|
54
|
+
return new File([arrayBuffer], this.name, {
|
|
55
|
+
lastModified: r2Object.uploaded.getTime(),
|
|
56
|
+
type: r2Object.httpMetadata?.contentType || this.#getMimeType(this.#key)
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async createWritable() {
|
|
60
|
+
return new R2FileSystemWritableFileStream(this.#r2Bucket, this.#key);
|
|
61
|
+
}
|
|
62
|
+
async createSyncAccessHandle() {
|
|
63
|
+
throw new DOMException(
|
|
64
|
+
"Synchronous access handles are not supported for R2 storage",
|
|
65
|
+
"InvalidStateError"
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
async isSameEntry(other) {
|
|
69
|
+
if (other.kind !== "file")
|
|
70
|
+
return false;
|
|
71
|
+
if (!(other instanceof _R2FileSystemFileHandle))
|
|
72
|
+
return false;
|
|
73
|
+
return this.#key === other.#key;
|
|
74
|
+
}
|
|
75
|
+
async queryPermission() {
|
|
76
|
+
return "granted";
|
|
77
|
+
}
|
|
78
|
+
async requestPermission() {
|
|
79
|
+
return "granted";
|
|
80
|
+
}
|
|
81
|
+
#getMimeType(key) {
|
|
82
|
+
return mime.getType(key) || "application/octet-stream";
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
var R2FileSystemDirectoryHandle = class _R2FileSystemDirectoryHandle {
|
|
86
|
+
kind;
|
|
87
|
+
name;
|
|
88
|
+
#r2Bucket;
|
|
89
|
+
#prefix;
|
|
90
|
+
constructor(r2Bucket, prefix) {
|
|
91
|
+
this.kind = "directory";
|
|
92
|
+
this.#r2Bucket = r2Bucket;
|
|
93
|
+
this.#prefix = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
94
|
+
this.name = this.#prefix.split("/").pop() || "root";
|
|
95
|
+
}
|
|
96
|
+
async getFileHandle(name, options) {
|
|
97
|
+
const key = this.#prefix ? `${this.#prefix}/${name}` : name;
|
|
98
|
+
const exists = await this.#r2Bucket.head(key);
|
|
99
|
+
if (!exists && options?.create) {
|
|
100
|
+
await this.#r2Bucket.put(key, new Uint8Array(0));
|
|
101
|
+
} else if (!exists) {
|
|
102
|
+
throw new DOMException("File not found", "NotFoundError");
|
|
103
|
+
}
|
|
104
|
+
return new R2FileSystemFileHandle(this.#r2Bucket, key);
|
|
105
|
+
}
|
|
106
|
+
async getDirectoryHandle(name, options) {
|
|
107
|
+
const newPrefix = this.#prefix ? `${this.#prefix}/${name}` : name;
|
|
108
|
+
if (options?.create) {
|
|
109
|
+
const markerKey = `${newPrefix}/.shovel_directory_marker`;
|
|
110
|
+
const exists = await this.#r2Bucket.head(markerKey);
|
|
111
|
+
if (!exists) {
|
|
112
|
+
await this.#r2Bucket.put(markerKey, new Uint8Array(0));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return new _R2FileSystemDirectoryHandle(this.#r2Bucket, newPrefix);
|
|
116
|
+
}
|
|
117
|
+
async removeEntry(name, options) {
|
|
118
|
+
const key = this.#prefix ? `${this.#prefix}/${name}` : name;
|
|
119
|
+
const fileExists = await this.#r2Bucket.head(key);
|
|
120
|
+
if (fileExists) {
|
|
121
|
+
await this.#r2Bucket.delete(key);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (options?.recursive) {
|
|
125
|
+
const dirPrefix = `${key}/`;
|
|
126
|
+
const listed = await this.#r2Bucket.list({ prefix: dirPrefix });
|
|
127
|
+
const deletePromises = listed.objects.map(
|
|
128
|
+
(object) => this.#r2Bucket.delete(object.key)
|
|
129
|
+
);
|
|
130
|
+
await Promise.all(deletePromises);
|
|
131
|
+
const markerKey = `${key}/.shovel_directory_marker`;
|
|
132
|
+
const markerExists = await this.#r2Bucket.head(markerKey);
|
|
133
|
+
if (markerExists) {
|
|
134
|
+
await this.#r2Bucket.delete(markerKey);
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
throw new DOMException(
|
|
138
|
+
"Directory is not empty",
|
|
139
|
+
"InvalidModificationError"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async resolve(_possibleDescendant) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
async *entries() {
|
|
147
|
+
const listPrefix = this.#prefix ? `${this.#prefix}/` : "";
|
|
148
|
+
try {
|
|
149
|
+
const result = await this.#r2Bucket.list({
|
|
150
|
+
prefix: listPrefix,
|
|
151
|
+
delimiter: "/"
|
|
152
|
+
// Only get immediate children
|
|
153
|
+
});
|
|
154
|
+
for (const object of result.objects) {
|
|
155
|
+
if (object.key !== listPrefix) {
|
|
156
|
+
const name = object.key.substring(listPrefix.length);
|
|
157
|
+
if (!name.includes("/") && !name.endsWith(".shovel_directory_marker")) {
|
|
158
|
+
yield [
|
|
159
|
+
name,
|
|
160
|
+
new R2FileSystemFileHandle(this.#r2Bucket, object.key)
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const prefix of result.delimitedPrefixes) {
|
|
166
|
+
const name = prefix.substring(listPrefix.length).replace(/\/$/, "");
|
|
167
|
+
if (name) {
|
|
168
|
+
yield [
|
|
169
|
+
name,
|
|
170
|
+
new _R2FileSystemDirectoryHandle(
|
|
171
|
+
this.#r2Bucket,
|
|
172
|
+
prefix.replace(/\/$/, "")
|
|
173
|
+
)
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch (error) {
|
|
178
|
+
throw new DOMException("Directory not found", "NotFoundError");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async *keys() {
|
|
182
|
+
for await (const [name] of this.entries()) {
|
|
183
|
+
yield name;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async *values() {
|
|
187
|
+
for await (const [, handle] of this.entries()) {
|
|
188
|
+
yield handle;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async isSameEntry(other) {
|
|
192
|
+
if (other.kind !== "directory")
|
|
193
|
+
return false;
|
|
194
|
+
if (!(other instanceof _R2FileSystemDirectoryHandle))
|
|
195
|
+
return false;
|
|
196
|
+
return this.#prefix === other.#prefix;
|
|
197
|
+
}
|
|
198
|
+
async queryPermission() {
|
|
199
|
+
return "granted";
|
|
200
|
+
}
|
|
201
|
+
async requestPermission() {
|
|
202
|
+
return "granted";
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
var R2FileSystemAdapter = class {
|
|
206
|
+
#config;
|
|
207
|
+
#r2Bucket;
|
|
208
|
+
constructor(r2Bucket, config = {}) {
|
|
209
|
+
this.#config = {
|
|
210
|
+
name: "r2",
|
|
211
|
+
...config
|
|
212
|
+
};
|
|
213
|
+
this.#r2Bucket = r2Bucket;
|
|
214
|
+
}
|
|
215
|
+
async getFileSystemRoot(name = "default") {
|
|
216
|
+
const prefix = `filesystems/${name}`;
|
|
217
|
+
return new R2FileSystemDirectoryHandle(this.#r2Bucket, prefix);
|
|
218
|
+
}
|
|
219
|
+
getConfig() {
|
|
220
|
+
return { ...this.#config };
|
|
221
|
+
}
|
|
222
|
+
async dispose() {
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
export {
|
|
226
|
+
R2FileSystemAdapter,
|
|
227
|
+
R2FileSystemDirectoryHandle,
|
|
228
|
+
R2FileSystemFileHandle,
|
|
229
|
+
R2FileSystemWritableFileStream
|
|
230
|
+
};
|
package/src/index.d.ts
CHANGED
|
@@ -2,23 +2,50 @@
|
|
|
2
2
|
* @b9g/platform-cloudflare - Cloudflare Workers platform adapter for Shovel
|
|
3
3
|
*
|
|
4
4
|
* Provides ServiceWorker-native deployment for Cloudflare Workers with KV/R2/D1 integration.
|
|
5
|
+
*
|
|
6
|
+
* Architecture:
|
|
7
|
+
* - Uses ServiceWorkerGlobals from @b9g/platform for full feature parity with Node/Bun
|
|
8
|
+
* - AsyncContext provides per-request access to Cloudflare's env/ctx
|
|
9
|
+
* - Directories use R2 via lazy factory (accessed when directories.open() is called)
|
|
10
|
+
* - Caches use Cloudflare's native Cache API
|
|
5
11
|
*/
|
|
6
|
-
import { BasePlatform, PlatformConfig, Handler, Server, ServerOptions, ServiceWorkerOptions, ServiceWorkerInstance } from "@b9g/platform";
|
|
12
|
+
import { BasePlatform, PlatformConfig, Handler, Server, ServerOptions, ServiceWorkerOptions, ServiceWorkerInstance, EntryWrapperOptions, PlatformEsbuildConfig } from "@b9g/platform";
|
|
13
|
+
import { CustomCacheStorage } from "@b9g/cache";
|
|
14
|
+
import { ShovelServiceWorkerRegistration } from "@b9g/platform/runtime";
|
|
15
|
+
import type { ExecutionContext } from "./cloudflare-runtime.js";
|
|
7
16
|
export type { Platform, Handler, Server, ServerOptions, ServiceWorkerOptions, ServiceWorkerInstance, } from "@b9g/platform";
|
|
17
|
+
/**
|
|
18
|
+
* Get the current request's Cloudflare env object
|
|
19
|
+
* Contains all bindings: KV namespaces, R2 buckets, D1 databases, etc.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getEnv<T = Record<string, unknown>>(): T | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* Get the current request's Cloudflare ExecutionContext
|
|
24
|
+
* Used for ctx.waitUntil() and other lifecycle methods
|
|
25
|
+
*/
|
|
26
|
+
export declare function getCtx(): ExecutionContext | undefined;
|
|
8
27
|
export interface CloudflarePlatformOptions extends PlatformConfig {
|
|
9
28
|
/** Cloudflare Workers environment (production, preview, dev) */
|
|
10
29
|
environment?: "production" | "preview" | "dev";
|
|
11
30
|
/** Static assets directory for ASSETS binding (dev mode) */
|
|
12
31
|
assetsDirectory?: string;
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
/** R2 bucket bindings */
|
|
16
|
-
r2Buckets?: Record<string, any>;
|
|
17
|
-
/** D1 database bindings */
|
|
18
|
-
d1Databases?: Record<string, any>;
|
|
19
|
-
/** Durable Object bindings */
|
|
20
|
-
durableObjects?: Record<string, any>;
|
|
32
|
+
/** Working directory for config file resolution */
|
|
33
|
+
cwd?: string;
|
|
21
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Initialize the Cloudflare runtime with ServiceWorkerGlobals
|
|
37
|
+
* Called once when the worker module loads (before user code runs)
|
|
38
|
+
*
|
|
39
|
+
* This sets up:
|
|
40
|
+
* - ServiceWorkerGlobals (caches, directories, cookieStore, addEventListener, etc.)
|
|
41
|
+
* - Per-request env/ctx via AsyncContext
|
|
42
|
+
*/
|
|
43
|
+
export declare function initializeRuntime(): ShovelServiceWorkerRegistration;
|
|
44
|
+
/**
|
|
45
|
+
* Create the ES module fetch handler for Cloudflare Workers
|
|
46
|
+
* This wraps requests with AsyncContext so env/ctx are available everywhere
|
|
47
|
+
*/
|
|
48
|
+
export declare function createFetchHandler(registration: ShovelServiceWorkerRegistration): (request: Request, env: unknown, ctx: ExecutionContext) => Promise<Response>;
|
|
22
49
|
/**
|
|
23
50
|
* Cloudflare Workers platform implementation
|
|
24
51
|
*/
|
|
@@ -27,28 +54,42 @@ export declare class CloudflarePlatform extends BasePlatform {
|
|
|
27
54
|
readonly name: string;
|
|
28
55
|
constructor(options?: CloudflarePlatformOptions);
|
|
29
56
|
/**
|
|
30
|
-
* Create
|
|
57
|
+
* Create cache storage
|
|
58
|
+
* Uses Cloudflare's native Cache API
|
|
59
|
+
*/
|
|
60
|
+
createCaches(): Promise<CustomCacheStorage>;
|
|
61
|
+
/**
|
|
62
|
+
* Create "server" for Cloudflare Workers (stub for Platform interface)
|
|
31
63
|
*/
|
|
32
64
|
createServer(handler: Handler, _options?: ServerOptions): Server;
|
|
33
65
|
/**
|
|
34
|
-
* Load ServiceWorker
|
|
35
|
-
*
|
|
36
|
-
* In production: Uses the native CF Worker environment
|
|
37
|
-
* In dev mode: Uses miniflare (workerd) for true dev/prod parity
|
|
66
|
+
* Load ServiceWorker using miniflare (workerd) for dev mode
|
|
38
67
|
*/
|
|
39
68
|
loadServiceWorker(entrypoint: string, _options?: ServiceWorkerOptions): Promise<ServiceWorkerInstance>;
|
|
69
|
+
dispose(): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* Get virtual entry wrapper for Cloudflare Workers
|
|
72
|
+
*
|
|
73
|
+
* Wraps user code with:
|
|
74
|
+
* 1. Config import (shovel:config virtual module)
|
|
75
|
+
* 2. Runtime initialization (ServiceWorkerGlobals)
|
|
76
|
+
* 3. User code import (registers fetch handlers)
|
|
77
|
+
* 4. ES module export for Cloudflare Workers format
|
|
78
|
+
*
|
|
79
|
+
* Note: Unlike Node/Bun, Cloudflare bundles user code inline, so the
|
|
80
|
+
* entryPath is embedded directly in the wrapper.
|
|
81
|
+
*/
|
|
82
|
+
getEntryWrapper(entryPath: string, _options?: EntryWrapperOptions): string;
|
|
40
83
|
/**
|
|
41
|
-
*
|
|
84
|
+
* Get Cloudflare-specific esbuild configuration
|
|
85
|
+
*
|
|
86
|
+
* Note: Cloudflare Workers natively support import.meta.env, so no define alias
|
|
87
|
+
* is needed. The nodejs_compat flag enables node:* built-in modules at runtime,
|
|
88
|
+
* so we externalize them during bundling.
|
|
42
89
|
*/
|
|
43
|
-
|
|
90
|
+
getEsbuildConfig(): PlatformEsbuildConfig;
|
|
44
91
|
}
|
|
45
|
-
/**
|
|
46
|
-
* Create platform options from Wrangler environment
|
|
47
|
-
*/
|
|
48
92
|
export declare function createOptionsFromEnv(env: any): CloudflarePlatformOptions;
|
|
49
|
-
/**
|
|
50
|
-
* Generate wrangler.toml configuration for a Shovel app from CLI flags
|
|
51
|
-
*/
|
|
52
93
|
export declare function generateWranglerConfig(options: {
|
|
53
94
|
name: string;
|
|
54
95
|
entrypoint: string;
|
|
@@ -58,15 +99,4 @@ export declare function generateWranglerConfig(options: {
|
|
|
58
99
|
r2Buckets?: string[];
|
|
59
100
|
d1Databases?: string[];
|
|
60
101
|
}): string;
|
|
61
|
-
/**
|
|
62
|
-
* Generate banner code for ServiceWorker → ES Module conversion
|
|
63
|
-
*/
|
|
64
|
-
export declare const cloudflareWorkerBanner = "// Cloudflare Worker ES Module wrapper\nlet serviceWorkerGlobals = null;\n\n// Set up ServiceWorker environment\nif (typeof globalThis.self === 'undefined') {\n\tglobalThis.self = globalThis;\n}\n\n// Capture fetch event handlers\nconst fetchHandlers = [];\nconst originalAddEventListener = globalThis.addEventListener;\nglobalThis.addEventListener = function(type, handler, options) {\n\tif (type === 'fetch') {\n\t\tfetchHandlers.push(handler);\n\t} else {\n\t\toriginalAddEventListener?.call(this, type, handler, options);\n\t}\n};\n\n// Create a promise-based FetchEvent that can be awaited\nclass FetchEvent {\n\tconstructor(type, init) {\n\t\tthis.type = type;\n\t\tthis.request = init.request;\n\t\tthis._response = null;\n\t\tthis._responsePromise = new Promise((resolve) => {\n\t\t\tthis._resolveResponse = resolve;\n\t\t});\n\t}\n\t\n\trespondWith(response) {\n\t\tthis._response = response;\n\t\tthis._resolveResponse(response);\n\t}\n\t\n\tasync waitUntil(promise) {\n\t\tawait promise;\n\t}\n}";
|
|
65
|
-
/**
|
|
66
|
-
* Generate footer code for ServiceWorker → ES Module conversion
|
|
67
|
-
*/
|
|
68
|
-
export declare const cloudflareWorkerFooter = "\n// Export ES Module for Cloudflare Workers\nexport default {\n\tasync fetch(request, env, ctx) {\n\t\ttry {\n\t\t\t// Set up ServiceWorker-like dirs API for bundled deployment\n\t\t\tif (!globalThis.self.dirs) {\n\t\t\t\t// For bundled deployment, assets are served via static middleware\n\t\t\t\t// not through the dirs API\n\t\t\t\tglobalThis.self.dirs = {\n\t\t\t\t\tasync open(directoryName) {\n\t\t\t\t\t\tif (directoryName === 'assets') {\n\t\t\t\t\t\t\t// Return a minimal interface that indicates no files available\n\t\t\t\t\t\t\t// The assets middleware will fall back to dev mode behavior\n\t\t\t\t\t\t\treturn {\n\t\t\t\t\t\t\t\tasync getFileHandle(fileName) {\n\t\t\t\t\t\t\t\t\tthrow new Error(`NotFoundError: ${fileName} not found in bundled assets`);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t};\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthrow new Error(`Directory ${directoryName} not available in bundled deployment`);\n\t\t\t\t\t}\n\t\t\t\t};\n\t\t\t}\n\t\t\t\n\t\t\t// Set up caches API\n\t\t\tif (!globalThis.self.caches) {\n\t\t\t\tglobalThis.self.caches = globalThis.caches;\n\t\t\t}\n\t\t\t\n\t\t\t// Ensure request.url is a string\n\t\t\tif (typeof request.url !== 'string') {\n\t\t\t\treturn new Response('Invalid request URL: ' + typeof request.url, { status: 500 });\n\t\t\t}\n\t\t\t\n\t\t\t// Create proper FetchEvent-like object\n\t\t\tlet responseReceived = null;\n\t\t\tconst event = { \n\t\t\t\trequest, \n\t\t\t\trespondWith: (response) => { responseReceived = response; }\n\t\t\t};\n\t\t\t\n\t\t\t// Helper for error responses\n\t\t\tconst createErrorResponse = (err) => {\n\t\t\t\tconst isDev = typeof import.meta !== \"undefined\" && import.meta.env?.MODE !== \"production\";\n\t\t\t\tif (isDev) {\n\t\t\t\t\tconst escapeHtml = (str) => str.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\").replace(/\"/g, \""\");\n\t\t\t\t\treturn new Response(`<!DOCTYPE html>\n<html>\n<head>\n <title>500 Internal Server Error</title>\n <style>\n body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }\n h1 { color: #c00; }\n .message { font-size: 1.2em; color: #333; }\n pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; border-radius: 4px; }\n </style>\n</head>\n<body>\n <h1>500 Internal Server Error</h1>\n <p class=\"message\">${escapeHtml(err.message)}</p>\n <pre>${escapeHtml(err.stack || \"No stack trace available\")}</pre>\n</body>\n</html>`, { status: 500, headers: { \"Content-Type\": \"text/html; charset=utf-8\" } });\n\t\t\t\t} else {\n\t\t\t\t\treturn new Response(\"Internal Server Error\", { status: 500, headers: { \"Content-Type\": \"text/plain\" } });\n\t\t\t\t}\n\t\t\t};\n\n\t\t\t// Dispatch to ServiceWorker fetch handlers\n\t\t\tfor (const handler of fetchHandlers) {\n\t\t\t\ttry {\n\t\t\t\t\tlogger.debug(\"Calling handler\", {url: request.url});\n\t\t\t\t\tawait handler(event);\n\t\t\t\t\tlogger.debug(\"Handler completed\", {hasResponse: !!responseReceived});\n\t\t\t\t\tif (responseReceived) {\n\t\t\t\t\t\treturn responseReceived;\n\t\t\t\t\t}\n\t\t\t\t} catch (error) {\n\t\t\t\t\tlogger.error(\"Handler error: {error}\", {error});\n\t\t\t\t\treturn createErrorResponse(error);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn new Response('No ServiceWorker handler', { status: 404 });\n\t\t} catch (topLevelError) {\n\t\t\tlogger.error(\"Top-level error: {error}\", {error: topLevelError});\n\t\t\tconst isDev = typeof import.meta !== \"undefined\" && import.meta.env?.MODE !== \"production\";\n\t\t\tif (isDev) {\n\t\t\t\tconst escapeHtml = (str) => String(str).replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\").replace(/\"/g, \""\");\n\t\t\t\treturn new Response(`<!DOCTYPE html>\n<html>\n<head>\n <title>500 Internal Server Error</title>\n <style>\n body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }\n h1 { color: #c00; }\n .message { font-size: 1.2em; color: #333; }\n pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; border-radius: 4px; }\n </style>\n</head>\n<body>\n <h1>500 Internal Server Error</h1>\n <p class=\"message\">${escapeHtml(topLevelError.message)}</p>\n <pre>${escapeHtml(topLevelError.stack || \"No stack trace available\")}</pre>\n</body>\n</html>`, { status: 500, headers: { \"Content-Type\": \"text/html; charset=utf-8\" } });\n\t\t\t} else {\n\t\t\t\treturn new Response(\"Internal Server Error\", { status: 500, headers: { \"Content-Type\": \"text/plain\" } });\n\t\t\t}\n\t\t}\n\t}\n};";
|
|
69
|
-
/**
|
|
70
|
-
* Default export for easy importing
|
|
71
|
-
*/
|
|
72
102
|
export default CloudflarePlatform;
|
package/src/index.js
CHANGED
|
@@ -3,33 +3,133 @@
|
|
|
3
3
|
import {
|
|
4
4
|
BasePlatform
|
|
5
5
|
} from "@b9g/platform";
|
|
6
|
+
import { CustomCacheStorage } from "@b9g/cache";
|
|
7
|
+
import {
|
|
8
|
+
ServiceWorkerGlobals,
|
|
9
|
+
ShovelServiceWorkerRegistration,
|
|
10
|
+
CustomLoggerStorage
|
|
11
|
+
} from "@b9g/platform/runtime";
|
|
12
|
+
import { CustomDirectoryStorage } from "@b9g/filesystem";
|
|
13
|
+
import { AsyncContext } from "@b9g/async-context";
|
|
6
14
|
import { getLogger } from "@logtape/logtape";
|
|
7
|
-
|
|
15
|
+
import { R2FileSystemDirectoryHandle } from "./filesystem-r2.js";
|
|
16
|
+
var logger = getLogger(["platform"]);
|
|
17
|
+
var envStorage = new AsyncContext.Variable();
|
|
18
|
+
var ctxStorage = new AsyncContext.Variable();
|
|
19
|
+
function getEnv() {
|
|
20
|
+
return envStorage.get();
|
|
21
|
+
}
|
|
22
|
+
function getCtx() {
|
|
23
|
+
return ctxStorage.get();
|
|
24
|
+
}
|
|
25
|
+
var _registration = null;
|
|
26
|
+
var _globals = null;
|
|
27
|
+
function initializeRuntime() {
|
|
28
|
+
if (_registration) {
|
|
29
|
+
return _registration;
|
|
30
|
+
}
|
|
31
|
+
_registration = new ShovelServiceWorkerRegistration();
|
|
32
|
+
const directories = new CustomDirectoryStorage(
|
|
33
|
+
createCloudflareR2DirectoryFactory()
|
|
34
|
+
);
|
|
35
|
+
_globals = new ServiceWorkerGlobals({
|
|
36
|
+
registration: _registration,
|
|
37
|
+
caches: globalThis.caches,
|
|
38
|
+
// Use Cloudflare's native Cache API
|
|
39
|
+
directories,
|
|
40
|
+
loggers: new CustomLoggerStorage((...cats) => getLogger(cats))
|
|
41
|
+
});
|
|
42
|
+
_globals.install();
|
|
43
|
+
return _registration;
|
|
44
|
+
}
|
|
45
|
+
function createFetchHandler(registration) {
|
|
46
|
+
return async (request, env, ctx) => {
|
|
47
|
+
return envStorage.run(
|
|
48
|
+
env,
|
|
49
|
+
() => ctxStorage.run(ctx, async () => {
|
|
50
|
+
try {
|
|
51
|
+
return await registration.handleRequest(request);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error("ServiceWorker error:", error);
|
|
54
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
55
|
+
const isDev = typeof import.meta !== "undefined" && import.meta.env?.MODE !== "production";
|
|
56
|
+
if (isDev) {
|
|
57
|
+
return new Response(
|
|
58
|
+
`<!DOCTYPE html>
|
|
59
|
+
<html>
|
|
60
|
+
<head><title>500 Internal Server Error</title>
|
|
61
|
+
<style>body{font-family:system-ui;padding:2rem;max-width:800px;margin:0 auto}h1{color:#c00}pre{background:#f5f5f5;padding:1rem;overflow-x:auto}</style>
|
|
62
|
+
</head>
|
|
63
|
+
<body>
|
|
64
|
+
<h1>500 Internal Server Error</h1>
|
|
65
|
+
<p>${escapeHtml(err.message)}</p>
|
|
66
|
+
<pre>${escapeHtml(err.stack || "No stack trace")}</pre>
|
|
67
|
+
</body></html>`,
|
|
68
|
+
{ status: 500, headers: { "Content-Type": "text/html" } }
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return new Response("Internal Server Error", { status: 500 });
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function escapeHtml(str) {
|
|
78
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
79
|
+
}
|
|
80
|
+
function createCloudflareR2DirectoryFactory() {
|
|
81
|
+
return async (name) => {
|
|
82
|
+
const env = getEnv();
|
|
83
|
+
if (!env) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Cannot access directory "${name}": Cloudflare env not available. This usually means you're trying to access directories outside of a request context.`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const bindingName = `${name.toUpperCase()}_R2`;
|
|
89
|
+
const r2Bucket = env[bindingName];
|
|
90
|
+
if (!r2Bucket) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`R2 bucket binding "${bindingName}" not found in env. Configure in wrangler.toml:
|
|
93
|
+
|
|
94
|
+
[[r2_buckets]]
|
|
95
|
+
binding = "${bindingName}"
|
|
96
|
+
bucket_name = "your-bucket-name"`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return new R2FileSystemDirectoryHandle(r2Bucket, "");
|
|
100
|
+
};
|
|
101
|
+
}
|
|
8
102
|
var CloudflarePlatform = class extends BasePlatform {
|
|
9
103
|
name;
|
|
10
104
|
#options;
|
|
11
105
|
#miniflare;
|
|
12
106
|
#assetsMiniflare;
|
|
13
|
-
// Separate instance for ASSETS binding
|
|
14
|
-
#assetsBinding;
|
|
15
107
|
constructor(options = {}) {
|
|
16
108
|
super(options);
|
|
17
109
|
this.#miniflare = null;
|
|
18
110
|
this.#assetsMiniflare = null;
|
|
19
|
-
this.#assetsBinding = null;
|
|
20
111
|
this.name = "cloudflare";
|
|
112
|
+
const cwd = options.cwd ?? ".";
|
|
21
113
|
this.#options = {
|
|
22
|
-
environment: "production",
|
|
23
|
-
assetsDirectory:
|
|
24
|
-
|
|
25
|
-
r2Buckets: {},
|
|
26
|
-
d1Databases: {},
|
|
27
|
-
durableObjects: {},
|
|
28
|
-
...options
|
|
114
|
+
environment: options.environment ?? "production",
|
|
115
|
+
assetsDirectory: options.assetsDirectory,
|
|
116
|
+
cwd
|
|
29
117
|
};
|
|
30
118
|
}
|
|
31
119
|
/**
|
|
32
|
-
* Create
|
|
120
|
+
* Create cache storage
|
|
121
|
+
* Uses Cloudflare's native Cache API
|
|
122
|
+
*/
|
|
123
|
+
async createCaches() {
|
|
124
|
+
return new CustomCacheStorage(async (name) => {
|
|
125
|
+
if (globalThis.caches) {
|
|
126
|
+
return globalThis.caches.open(name);
|
|
127
|
+
}
|
|
128
|
+
throw new Error("Cloudflare caches not available in this context");
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Create "server" for Cloudflare Workers (stub for Platform interface)
|
|
33
133
|
*/
|
|
34
134
|
createServer(handler, _options = {}) {
|
|
35
135
|
return {
|
|
@@ -48,55 +148,22 @@ var CloudflarePlatform = class extends BasePlatform {
|
|
|
48
148
|
}
|
|
49
149
|
};
|
|
50
150
|
}
|
|
51
|
-
/**
|
|
52
|
-
* Load ServiceWorker-style entrypoint in Cloudflare Workers
|
|
53
|
-
*
|
|
54
|
-
* In production: Uses the native CF Worker environment
|
|
55
|
-
* In dev mode: Uses miniflare (workerd) for true dev/prod parity
|
|
56
|
-
*/
|
|
57
|
-
async loadServiceWorker(entrypoint, _options = {}) {
|
|
58
|
-
const isCloudflareWorker = typeof globalThis.addEventListener === "function" && typeof globalThis.caches !== "undefined" && typeof globalThis.FetchEvent !== "undefined";
|
|
59
|
-
if (isCloudflareWorker) {
|
|
60
|
-
logger.info("Running in native ServiceWorker environment", {});
|
|
61
|
-
const instance = {
|
|
62
|
-
runtime: globalThis,
|
|
63
|
-
handleRequest: async (request) => {
|
|
64
|
-
const event = new FetchEvent("fetch", { request });
|
|
65
|
-
globalThis.dispatchEvent(event);
|
|
66
|
-
return new Response("Worker handler", { status: 200 });
|
|
67
|
-
},
|
|
68
|
-
install: () => Promise.resolve(),
|
|
69
|
-
activate: () => Promise.resolve(),
|
|
70
|
-
get ready() {
|
|
71
|
-
return true;
|
|
72
|
-
},
|
|
73
|
-
dispose: async () => {
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
await import(entrypoint);
|
|
77
|
-
return instance;
|
|
78
|
-
} else {
|
|
79
|
-
return this.#loadServiceWorkerWithMiniflare(entrypoint);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
151
|
/**
|
|
83
152
|
* Load ServiceWorker using miniflare (workerd) for dev mode
|
|
84
153
|
*/
|
|
85
|
-
async
|
|
154
|
+
async loadServiceWorker(entrypoint, _options = {}) {
|
|
86
155
|
logger.info("Starting miniflare dev server", { entrypoint });
|
|
87
156
|
const { Miniflare } = await import("miniflare");
|
|
88
157
|
const miniflareOptions = {
|
|
89
|
-
modules:
|
|
90
|
-
// ServiceWorker format (not ES modules)
|
|
158
|
+
modules: true,
|
|
91
159
|
scriptPath: entrypoint,
|
|
92
|
-
// Enable CF-compatible APIs
|
|
93
160
|
compatibilityDate: "2024-09-23",
|
|
94
161
|
compatibilityFlags: ["nodejs_compat"]
|
|
95
162
|
};
|
|
96
163
|
this.#miniflare = new Miniflare(miniflareOptions);
|
|
97
164
|
await this.#miniflare.ready;
|
|
98
165
|
if (this.#options.assetsDirectory) {
|
|
99
|
-
logger.info("Setting up
|
|
166
|
+
logger.info("Setting up ASSETS binding", {
|
|
100
167
|
directory: this.#options.assetsDirectory
|
|
101
168
|
});
|
|
102
169
|
this.#assetsMiniflare = new Miniflare({
|
|
@@ -108,11 +175,7 @@ var CloudflarePlatform = class extends BasePlatform {
|
|
|
108
175
|
},
|
|
109
176
|
compatibilityDate: "2024-09-23"
|
|
110
177
|
});
|
|
111
|
-
|
|
112
|
-
if (assetsEnv.ASSETS) {
|
|
113
|
-
this.#assetsBinding = assetsEnv.ASSETS;
|
|
114
|
-
logger.info("ASSETS binding available", {});
|
|
115
|
-
}
|
|
178
|
+
await this.#assetsMiniflare.ready;
|
|
116
179
|
}
|
|
117
180
|
const mf = this.#miniflare;
|
|
118
181
|
const instance = {
|
|
@@ -142,9 +205,6 @@ var CloudflarePlatform = class extends BasePlatform {
|
|
|
142
205
|
logger.info("Miniflare dev server ready", {});
|
|
143
206
|
return instance;
|
|
144
207
|
}
|
|
145
|
-
/**
|
|
146
|
-
* Dispose of platform resources
|
|
147
|
-
*/
|
|
148
208
|
async dispose() {
|
|
149
209
|
if (this.#miniflare) {
|
|
150
210
|
await this.#miniflare.dispose();
|
|
@@ -154,67 +214,75 @@ var CloudflarePlatform = class extends BasePlatform {
|
|
|
154
214
|
await this.#assetsMiniflare.dispose();
|
|
155
215
|
this.#assetsMiniflare = null;
|
|
156
216
|
}
|
|
157
|
-
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Get virtual entry wrapper for Cloudflare Workers
|
|
220
|
+
*
|
|
221
|
+
* Wraps user code with:
|
|
222
|
+
* 1. Config import (shovel:config virtual module)
|
|
223
|
+
* 2. Runtime initialization (ServiceWorkerGlobals)
|
|
224
|
+
* 3. User code import (registers fetch handlers)
|
|
225
|
+
* 4. ES module export for Cloudflare Workers format
|
|
226
|
+
*
|
|
227
|
+
* Note: Unlike Node/Bun, Cloudflare bundles user code inline, so the
|
|
228
|
+
* entryPath is embedded directly in the wrapper.
|
|
229
|
+
*/
|
|
230
|
+
getEntryWrapper(entryPath, _options) {
|
|
231
|
+
const safePath = JSON.stringify(entryPath);
|
|
232
|
+
return `// Cloudflare Worker Entry - uses ServiceWorkerGlobals for feature parity with Node/Bun
|
|
233
|
+
import { initializeRuntime, createFetchHandler } from "@b9g/platform-cloudflare/cloudflare-runtime";
|
|
234
|
+
import { configureLogging } from "@b9g/platform/runtime";
|
|
235
|
+
import { config } from "shovel:config"; // Virtual module - resolved at build time
|
|
236
|
+
|
|
237
|
+
// Configure logging before anything else
|
|
238
|
+
await configureLogging(config.logging);
|
|
239
|
+
|
|
240
|
+
// Initialize runtime BEFORE user code (installs globals like addEventListener)
|
|
241
|
+
const registration = initializeRuntime();
|
|
242
|
+
|
|
243
|
+
// Import user's ServiceWorker code (calls addEventListener('fetch', ...))
|
|
244
|
+
import ${safePath};
|
|
245
|
+
|
|
246
|
+
// Export ES module handler for Cloudflare Workers
|
|
247
|
+
export default {
|
|
248
|
+
fetch: createFetchHandler(registration)
|
|
249
|
+
};
|
|
250
|
+
`;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Get Cloudflare-specific esbuild configuration
|
|
254
|
+
*
|
|
255
|
+
* Note: Cloudflare Workers natively support import.meta.env, so no define alias
|
|
256
|
+
* is needed. The nodejs_compat flag enables node:* built-in modules at runtime,
|
|
257
|
+
* so we externalize them during bundling.
|
|
258
|
+
*/
|
|
259
|
+
getEsbuildConfig() {
|
|
260
|
+
return {
|
|
261
|
+
platform: "browser",
|
|
262
|
+
conditions: ["worker", "browser"],
|
|
263
|
+
// Externalize node:* builtins - available at runtime via nodejs_compat flag
|
|
264
|
+
external: ["node:*"],
|
|
265
|
+
// Cloudflare bundles user code inline via `import "user-entry"`
|
|
266
|
+
bundlesUserCodeInline: true
|
|
267
|
+
};
|
|
158
268
|
}
|
|
159
269
|
};
|
|
160
270
|
function createOptionsFromEnv(env) {
|
|
161
271
|
return {
|
|
162
|
-
environment: env.ENVIRONMENT || "production"
|
|
163
|
-
kvNamespaces: extractKVNamespaces(env),
|
|
164
|
-
r2Buckets: extractR2Buckets(env),
|
|
165
|
-
d1Databases: extractD1Databases(env),
|
|
166
|
-
durableObjects: extractDurableObjects(env)
|
|
272
|
+
environment: env.ENVIRONMENT || "production"
|
|
167
273
|
};
|
|
168
274
|
}
|
|
169
|
-
function extractKVNamespaces(env) {
|
|
170
|
-
const kvNamespaces = {};
|
|
171
|
-
for (const [key, value] of Object.entries(env)) {
|
|
172
|
-
if (key.endsWith("_KV") || key.includes("KV")) {
|
|
173
|
-
kvNamespaces[key] = value;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return kvNamespaces;
|
|
177
|
-
}
|
|
178
|
-
function extractR2Buckets(env) {
|
|
179
|
-
const r2Buckets = {};
|
|
180
|
-
for (const [key, value] of Object.entries(env)) {
|
|
181
|
-
if (key.endsWith("_R2") || key.includes("R2")) {
|
|
182
|
-
r2Buckets[key] = value;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
return r2Buckets;
|
|
186
|
-
}
|
|
187
|
-
function extractD1Databases(env) {
|
|
188
|
-
const d1Databases = {};
|
|
189
|
-
for (const [key, value] of Object.entries(env)) {
|
|
190
|
-
if (key.endsWith("_D1") || key.includes("D1") || key.endsWith("_DB")) {
|
|
191
|
-
d1Databases[key] = value;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
return d1Databases;
|
|
195
|
-
}
|
|
196
|
-
function extractDurableObjects(env) {
|
|
197
|
-
const durableObjects = {};
|
|
198
|
-
for (const [key, value] of Object.entries(env)) {
|
|
199
|
-
if (key.endsWith("_DO") || key.includes("DURABLE")) {
|
|
200
|
-
durableObjects[key] = value;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
return durableObjects;
|
|
204
|
-
}
|
|
205
275
|
function generateWranglerConfig(options) {
|
|
206
276
|
const {
|
|
207
277
|
name,
|
|
208
278
|
entrypoint,
|
|
209
|
-
cacheAdapter: _cacheAdapter,
|
|
210
279
|
filesystemAdapter,
|
|
211
280
|
kvNamespaces = [],
|
|
212
281
|
r2Buckets = [],
|
|
213
282
|
d1Databases = []
|
|
214
283
|
} = options;
|
|
215
|
-
const autoKVNamespaces = [];
|
|
216
284
|
const autoR2Buckets = filesystemAdapter === "r2" ? ["STORAGE_R2"] : [];
|
|
217
|
-
const allKVNamespaces = [
|
|
285
|
+
const allKVNamespaces = [...new Set(kvNamespaces)];
|
|
218
286
|
const allR2Buckets = [.../* @__PURE__ */ new Set([...r2Buckets, ...autoR2Buckets])];
|
|
219
287
|
return `# Generated wrangler.toml for Shovel app
|
|
220
288
|
name = "${name}"
|
|
@@ -222,189 +290,28 @@ main = "${entrypoint}"
|
|
|
222
290
|
compatibility_date = "2024-09-23"
|
|
223
291
|
compatibility_flags = ["nodejs_compat"]
|
|
224
292
|
|
|
225
|
-
|
|
226
|
-
usage_model = "bundled"
|
|
227
|
-
|
|
228
|
-
# KV bindings${allKVNamespaces.length > 0 ? "\n" + allKVNamespaces.map(
|
|
229
|
-
(kv) => `[[kv_namespaces]]
|
|
293
|
+
${allKVNamespaces.length > 0 ? allKVNamespaces.map((kv) => `[[kv_namespaces]]
|
|
230
294
|
binding = "${kv}"
|
|
231
|
-
id = "your-kv-
|
|
232
|
-
preview_id = "your-preview-kv-namespace-id"`
|
|
233
|
-
).join("\n\n") : ""}
|
|
295
|
+
id = "your-kv-id"`).join("\n\n") : "# No KV namespaces configured"}
|
|
234
296
|
|
|
235
|
-
|
|
236
|
-
(bucket) => `[[r2_buckets]]
|
|
297
|
+
${allR2Buckets.length > 0 ? allR2Buckets.map((bucket) => `[[r2_buckets]]
|
|
237
298
|
binding = "${bucket}"
|
|
238
|
-
bucket_name = "your-bucket-name"`
|
|
239
|
-
).join("\n\n") : ""}
|
|
299
|
+
bucket_name = "your-bucket-name"`).join("\n\n") : "# No R2 buckets configured"}
|
|
240
300
|
|
|
241
|
-
|
|
242
|
-
${d1Databases.map(
|
|
243
|
-
(db) => `[[d1_databases]]
|
|
301
|
+
${d1Databases.length > 0 ? d1Databases.map((db) => `[[d1_databases]]
|
|
244
302
|
binding = "${db}"
|
|
245
|
-
database_name = "your-
|
|
246
|
-
database_id = "your-
|
|
247
|
-
).join("\n\n")}
|
|
303
|
+
database_name = "your-db-name"
|
|
304
|
+
database_id = "your-db-id"`).join("\n\n") : "# No D1 databases configured"}
|
|
248
305
|
`;
|
|
249
306
|
}
|
|
250
|
-
var cloudflareWorkerBanner = `// Cloudflare Worker ES Module wrapper
|
|
251
|
-
let serviceWorkerGlobals = null;
|
|
252
|
-
|
|
253
|
-
// Set up ServiceWorker environment
|
|
254
|
-
if (typeof globalThis.self === 'undefined') {
|
|
255
|
-
globalThis.self = globalThis;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Capture fetch event handlers
|
|
259
|
-
const fetchHandlers = [];
|
|
260
|
-
const originalAddEventListener = globalThis.addEventListener;
|
|
261
|
-
globalThis.addEventListener = function(type, handler, options) {
|
|
262
|
-
if (type === 'fetch') {
|
|
263
|
-
fetchHandlers.push(handler);
|
|
264
|
-
} else {
|
|
265
|
-
originalAddEventListener?.call(this, type, handler, options);
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
// Create a promise-based FetchEvent that can be awaited
|
|
270
|
-
class FetchEvent {
|
|
271
|
-
constructor(type, init) {
|
|
272
|
-
this.type = type;
|
|
273
|
-
this.request = init.request;
|
|
274
|
-
this._response = null;
|
|
275
|
-
this._responsePromise = new Promise((resolve) => {
|
|
276
|
-
this._resolveResponse = resolve;
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
respondWith(response) {
|
|
281
|
-
this._response = response;
|
|
282
|
-
this._resolveResponse(response);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
async waitUntil(promise) {
|
|
286
|
-
await promise;
|
|
287
|
-
}
|
|
288
|
-
}`;
|
|
289
|
-
var cloudflareWorkerFooter = `
|
|
290
|
-
// Export ES Module for Cloudflare Workers
|
|
291
|
-
export default {
|
|
292
|
-
async fetch(request, env, ctx) {
|
|
293
|
-
try {
|
|
294
|
-
// Set up ServiceWorker-like dirs API for bundled deployment
|
|
295
|
-
if (!globalThis.self.dirs) {
|
|
296
|
-
// For bundled deployment, assets are served via static middleware
|
|
297
|
-
// not through the dirs API
|
|
298
|
-
globalThis.self.dirs = {
|
|
299
|
-
async open(directoryName) {
|
|
300
|
-
if (directoryName === 'assets') {
|
|
301
|
-
// Return a minimal interface that indicates no files available
|
|
302
|
-
// The assets middleware will fall back to dev mode behavior
|
|
303
|
-
return {
|
|
304
|
-
async getFileHandle(fileName) {
|
|
305
|
-
throw new Error(\`NotFoundError: \${fileName} not found in bundled assets\`);
|
|
306
|
-
}
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
throw new Error(\`Directory \${directoryName} not available in bundled deployment\`);
|
|
310
|
-
}
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Set up caches API
|
|
315
|
-
if (!globalThis.self.caches) {
|
|
316
|
-
globalThis.self.caches = globalThis.caches;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// Ensure request.url is a string
|
|
320
|
-
if (typeof request.url !== 'string') {
|
|
321
|
-
return new Response('Invalid request URL: ' + typeof request.url, { status: 500 });
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Create proper FetchEvent-like object
|
|
325
|
-
let responseReceived = null;
|
|
326
|
-
const event = {
|
|
327
|
-
request,
|
|
328
|
-
respondWith: (response) => { responseReceived = response; }
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
// Helper for error responses
|
|
332
|
-
const createErrorResponse = (err) => {
|
|
333
|
-
const isDev = typeof import.meta !== "undefined" && import.meta.env?.MODE !== "production";
|
|
334
|
-
if (isDev) {
|
|
335
|
-
const escapeHtml = (str) => str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
336
|
-
return new Response(\`<!DOCTYPE html>
|
|
337
|
-
<html>
|
|
338
|
-
<head>
|
|
339
|
-
<title>500 Internal Server Error</title>
|
|
340
|
-
<style>
|
|
341
|
-
body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
|
342
|
-
h1 { color: #c00; }
|
|
343
|
-
.message { font-size: 1.2em; color: #333; }
|
|
344
|
-
pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; border-radius: 4px; }
|
|
345
|
-
</style>
|
|
346
|
-
</head>
|
|
347
|
-
<body>
|
|
348
|
-
<h1>500 Internal Server Error</h1>
|
|
349
|
-
<p class="message">\${escapeHtml(err.message)}</p>
|
|
350
|
-
<pre>\${escapeHtml(err.stack || "No stack trace available")}</pre>
|
|
351
|
-
</body>
|
|
352
|
-
</html>\`, { status: 500, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
353
|
-
} else {
|
|
354
|
-
return new Response("Internal Server Error", { status: 500, headers: { "Content-Type": "text/plain" } });
|
|
355
|
-
}
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
// Dispatch to ServiceWorker fetch handlers
|
|
359
|
-
for (const handler of fetchHandlers) {
|
|
360
|
-
try {
|
|
361
|
-
logger.debug("Calling handler", {url: request.url});
|
|
362
|
-
await handler(event);
|
|
363
|
-
logger.debug("Handler completed", {hasResponse: !!responseReceived});
|
|
364
|
-
if (responseReceived) {
|
|
365
|
-
return responseReceived;
|
|
366
|
-
}
|
|
367
|
-
} catch (error) {
|
|
368
|
-
logger.error("Handler error: {error}", {error});
|
|
369
|
-
return createErrorResponse(error);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
return new Response('No ServiceWorker handler', { status: 404 });
|
|
374
|
-
} catch (topLevelError) {
|
|
375
|
-
logger.error("Top-level error: {error}", {error: topLevelError});
|
|
376
|
-
const isDev = typeof import.meta !== "undefined" && import.meta.env?.MODE !== "production";
|
|
377
|
-
if (isDev) {
|
|
378
|
-
const escapeHtml = (str) => String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
379
|
-
return new Response(\`<!DOCTYPE html>
|
|
380
|
-
<html>
|
|
381
|
-
<head>
|
|
382
|
-
<title>500 Internal Server Error</title>
|
|
383
|
-
<style>
|
|
384
|
-
body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
|
385
|
-
h1 { color: #c00; }
|
|
386
|
-
.message { font-size: 1.2em; color: #333; }
|
|
387
|
-
pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; border-radius: 4px; }
|
|
388
|
-
</style>
|
|
389
|
-
</head>
|
|
390
|
-
<body>
|
|
391
|
-
<h1>500 Internal Server Error</h1>
|
|
392
|
-
<p class="message">\${escapeHtml(topLevelError.message)}</p>
|
|
393
|
-
<pre>\${escapeHtml(topLevelError.stack || "No stack trace available")}</pre>
|
|
394
|
-
</body>
|
|
395
|
-
</html>\`, { status: 500, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
396
|
-
} else {
|
|
397
|
-
return new Response("Internal Server Error", { status: 500, headers: { "Content-Type": "text/plain" } });
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
};`;
|
|
402
307
|
var src_default = CloudflarePlatform;
|
|
403
308
|
export {
|
|
404
309
|
CloudflarePlatform,
|
|
405
|
-
|
|
406
|
-
cloudflareWorkerFooter,
|
|
310
|
+
createFetchHandler,
|
|
407
311
|
createOptionsFromEnv,
|
|
408
312
|
src_default as default,
|
|
409
|
-
generateWranglerConfig
|
|
313
|
+
generateWranglerConfig,
|
|
314
|
+
getCtx,
|
|
315
|
+
getEnv,
|
|
316
|
+
initializeRuntime
|
|
410
317
|
};
|