@b9g/platform-cloudflare 0.1.9 → 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 +19 -3
- 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 +56 -31
- package/src/index.js +170 -215
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",
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@b9g/assets": "^0.1.15",
|
|
15
15
|
"@b9g/cache": "^0.1.5",
|
|
16
|
-
"@b9g/platform": "^0.1.
|
|
17
|
-
"@cloudflare/workers-types": "^4.20241218.0",
|
|
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,10 +2,28 @@
|
|
|
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";
|
|
7
13
|
import { CustomCacheStorage } from "@b9g/cache";
|
|
14
|
+
import { ShovelServiceWorkerRegistration } from "@b9g/platform/runtime";
|
|
15
|
+
import type { ExecutionContext } from "./cloudflare-runtime.js";
|
|
8
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;
|
|
9
27
|
export interface CloudflarePlatformOptions extends PlatformConfig {
|
|
10
28
|
/** Cloudflare Workers environment (production, preview, dev) */
|
|
11
29
|
environment?: "production" | "preview" | "dev";
|
|
@@ -14,6 +32,20 @@ export interface CloudflarePlatformOptions extends PlatformConfig {
|
|
|
14
32
|
/** Working directory for config file resolution */
|
|
15
33
|
cwd?: string;
|
|
16
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>;
|
|
17
49
|
/**
|
|
18
50
|
* Cloudflare Workers platform implementation
|
|
19
51
|
*/
|
|
@@ -23,37 +55,41 @@ export declare class CloudflarePlatform extends BasePlatform {
|
|
|
23
55
|
constructor(options?: CloudflarePlatformOptions);
|
|
24
56
|
/**
|
|
25
57
|
* Create cache storage
|
|
26
|
-
* Uses
|
|
27
|
-
*
|
|
28
|
-
* Note: This is for the platform/test runner context. Inside actual
|
|
29
|
-
* Cloudflare Workers, native caches are available via globalThis.caches
|
|
30
|
-
* (captured by the banner as globalThis.__cloudflareCaches).
|
|
58
|
+
* Uses Cloudflare's native Cache API
|
|
31
59
|
*/
|
|
32
60
|
createCaches(): Promise<CustomCacheStorage>;
|
|
33
61
|
/**
|
|
34
|
-
* Create "server" for Cloudflare Workers (
|
|
62
|
+
* Create "server" for Cloudflare Workers (stub for Platform interface)
|
|
35
63
|
*/
|
|
36
64
|
createServer(handler: Handler, _options?: ServerOptions): Server;
|
|
37
65
|
/**
|
|
38
|
-
* Load ServiceWorker
|
|
39
|
-
*
|
|
40
|
-
* Note: In production Cloudflare Workers, the banner/footer wrapper code
|
|
41
|
-
* handles request dispatch directly - loadServiceWorker is only used for
|
|
42
|
-
* local development with miniflare.
|
|
66
|
+
* Load ServiceWorker using miniflare (workerd) for dev mode
|
|
43
67
|
*/
|
|
44
68
|
loadServiceWorker(entrypoint: string, _options?: ServiceWorkerOptions): Promise<ServiceWorkerInstance>;
|
|
69
|
+
dispose(): Promise<void>;
|
|
45
70
|
/**
|
|
46
|
-
*
|
|
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.
|
|
47
81
|
*/
|
|
48
|
-
|
|
82
|
+
getEntryWrapper(entryPath: string, _options?: EntryWrapperOptions): string;
|
|
83
|
+
/**
|
|
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.
|
|
89
|
+
*/
|
|
90
|
+
getEsbuildConfig(): PlatformEsbuildConfig;
|
|
49
91
|
}
|
|
50
|
-
/**
|
|
51
|
-
* Create platform options from Wrangler environment
|
|
52
|
-
*/
|
|
53
92
|
export declare function createOptionsFromEnv(env: any): CloudflarePlatformOptions;
|
|
54
|
-
/**
|
|
55
|
-
* Generate wrangler.toml configuration for a Shovel app from CLI flags
|
|
56
|
-
*/
|
|
57
93
|
export declare function generateWranglerConfig(options: {
|
|
58
94
|
name: string;
|
|
59
95
|
entrypoint: string;
|
|
@@ -63,15 +99,4 @@ export declare function generateWranglerConfig(options: {
|
|
|
63
99
|
r2Buckets?: string[];
|
|
64
100
|
d1Databases?: string[];
|
|
65
101
|
}): string;
|
|
66
|
-
/**
|
|
67
|
-
* Generate banner code for ServiceWorker → ES Module conversion
|
|
68
|
-
*/
|
|
69
|
-
export declare const cloudflareWorkerBanner = "// Cloudflare Worker ES Module wrapper\nlet serviceWorkerGlobals = null;\n\n// Capture native Cloudflare caches before any framework code runs\nconst nativeCaches = globalThis.caches;\n\n// Set up ServiceWorker environment\nif (typeof globalThis.self === 'undefined') {\n\tglobalThis.self = globalThis;\n}\n\n// Store native caches for access via globalThis.__cloudflareCaches\nglobalThis.__cloudflareCaches = nativeCaches;\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}";
|
|
70
|
-
/**
|
|
71
|
-
* Generate footer code for ServiceWorker → ES Module conversion
|
|
72
|
-
*/
|
|
73
|
-
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\tawait handler(event);\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\tconsole.error(\"Handler 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\tconsole.error(\"Top-level 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};";
|
|
74
|
-
/**
|
|
75
|
-
* Default export for easy importing
|
|
76
|
-
*/
|
|
77
102
|
export default CloudflarePlatform;
|
package/src/index.js
CHANGED
|
@@ -1,27 +1,115 @@
|
|
|
1
1
|
/// <reference types="./index.d.ts" />
|
|
2
2
|
// src/index.ts
|
|
3
3
|
import {
|
|
4
|
-
BasePlatform
|
|
5
|
-
loadConfig,
|
|
6
|
-
createCacheFactory
|
|
4
|
+
BasePlatform
|
|
7
5
|
} from "@b9g/platform";
|
|
8
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";
|
|
9
14
|
import { getLogger } from "@logtape/logtape";
|
|
10
|
-
|
|
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
|
+
}
|
|
11
102
|
var CloudflarePlatform = class extends BasePlatform {
|
|
12
103
|
name;
|
|
13
104
|
#options;
|
|
14
105
|
#miniflare;
|
|
15
106
|
#assetsMiniflare;
|
|
16
|
-
// Separate instance for ASSETS binding
|
|
17
|
-
#config;
|
|
18
107
|
constructor(options = {}) {
|
|
19
108
|
super(options);
|
|
20
109
|
this.#miniflare = null;
|
|
21
110
|
this.#assetsMiniflare = null;
|
|
22
111
|
this.name = "cloudflare";
|
|
23
112
|
const cwd = options.cwd ?? ".";
|
|
24
|
-
this.#config = loadConfig(cwd);
|
|
25
113
|
this.#options = {
|
|
26
114
|
environment: options.environment ?? "production",
|
|
27
115
|
assetsDirectory: options.assetsDirectory,
|
|
@@ -30,19 +118,18 @@ var CloudflarePlatform = class extends BasePlatform {
|
|
|
30
118
|
}
|
|
31
119
|
/**
|
|
32
120
|
* Create cache storage
|
|
33
|
-
* Uses
|
|
34
|
-
*
|
|
35
|
-
* Note: This is for the platform/test runner context. Inside actual
|
|
36
|
-
* Cloudflare Workers, native caches are available via globalThis.caches
|
|
37
|
-
* (captured by the banner as globalThis.__cloudflareCaches).
|
|
121
|
+
* Uses Cloudflare's native Cache API
|
|
38
122
|
*/
|
|
39
123
|
async createCaches() {
|
|
40
|
-
return new CustomCacheStorage(
|
|
41
|
-
|
|
42
|
-
|
|
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
|
+
});
|
|
43
130
|
}
|
|
44
131
|
/**
|
|
45
|
-
* Create "server" for Cloudflare Workers (
|
|
132
|
+
* Create "server" for Cloudflare Workers (stub for Platform interface)
|
|
46
133
|
*/
|
|
47
134
|
createServer(handler, _options = {}) {
|
|
48
135
|
return {
|
|
@@ -61,34 +148,22 @@ var CloudflarePlatform = class extends BasePlatform {
|
|
|
61
148
|
}
|
|
62
149
|
};
|
|
63
150
|
}
|
|
64
|
-
/**
|
|
65
|
-
* Load ServiceWorker-style entrypoint using miniflare (workerd)
|
|
66
|
-
*
|
|
67
|
-
* Note: In production Cloudflare Workers, the banner/footer wrapper code
|
|
68
|
-
* handles request dispatch directly - loadServiceWorker is only used for
|
|
69
|
-
* local development with miniflare.
|
|
70
|
-
*/
|
|
71
|
-
async loadServiceWorker(entrypoint, _options = {}) {
|
|
72
|
-
return this.#loadServiceWorkerWithMiniflare(entrypoint);
|
|
73
|
-
}
|
|
74
151
|
/**
|
|
75
152
|
* Load ServiceWorker using miniflare (workerd) for dev mode
|
|
76
153
|
*/
|
|
77
|
-
async
|
|
154
|
+
async loadServiceWorker(entrypoint, _options = {}) {
|
|
78
155
|
logger.info("Starting miniflare dev server", { entrypoint });
|
|
79
156
|
const { Miniflare } = await import("miniflare");
|
|
80
157
|
const miniflareOptions = {
|
|
81
|
-
modules:
|
|
82
|
-
// ServiceWorker format (not ES modules)
|
|
158
|
+
modules: true,
|
|
83
159
|
scriptPath: entrypoint,
|
|
84
|
-
// Enable CF-compatible APIs
|
|
85
160
|
compatibilityDate: "2024-09-23",
|
|
86
161
|
compatibilityFlags: ["nodejs_compat"]
|
|
87
162
|
};
|
|
88
163
|
this.#miniflare = new Miniflare(miniflareOptions);
|
|
89
164
|
await this.#miniflare.ready;
|
|
90
165
|
if (this.#options.assetsDirectory) {
|
|
91
|
-
logger.info("Setting up
|
|
166
|
+
logger.info("Setting up ASSETS binding", {
|
|
92
167
|
directory: this.#options.assetsDirectory
|
|
93
168
|
});
|
|
94
169
|
this.#assetsMiniflare = new Miniflare({
|
|
@@ -101,7 +176,6 @@ var CloudflarePlatform = class extends BasePlatform {
|
|
|
101
176
|
compatibilityDate: "2024-09-23"
|
|
102
177
|
});
|
|
103
178
|
await this.#assetsMiniflare.ready;
|
|
104
|
-
logger.info("ASSETS binding available", {});
|
|
105
179
|
}
|
|
106
180
|
const mf = this.#miniflare;
|
|
107
181
|
const instance = {
|
|
@@ -131,9 +205,6 @@ var CloudflarePlatform = class extends BasePlatform {
|
|
|
131
205
|
logger.info("Miniflare dev server ready", {});
|
|
132
206
|
return instance;
|
|
133
207
|
}
|
|
134
|
-
/**
|
|
135
|
-
* Dispose of platform resources
|
|
136
|
-
*/
|
|
137
208
|
async dispose() {
|
|
138
209
|
if (this.#miniflare) {
|
|
139
210
|
await this.#miniflare.dispose();
|
|
@@ -144,6 +215,57 @@ var CloudflarePlatform = class extends BasePlatform {
|
|
|
144
215
|
this.#assetsMiniflare = null;
|
|
145
216
|
}
|
|
146
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
|
+
};
|
|
268
|
+
}
|
|
147
269
|
};
|
|
148
270
|
function createOptionsFromEnv(env) {
|
|
149
271
|
return {
|
|
@@ -154,15 +276,13 @@ function generateWranglerConfig(options) {
|
|
|
154
276
|
const {
|
|
155
277
|
name,
|
|
156
278
|
entrypoint,
|
|
157
|
-
cacheAdapter: _cacheAdapter,
|
|
158
279
|
filesystemAdapter,
|
|
159
280
|
kvNamespaces = [],
|
|
160
281
|
r2Buckets = [],
|
|
161
282
|
d1Databases = []
|
|
162
283
|
} = options;
|
|
163
|
-
const autoKVNamespaces = [];
|
|
164
284
|
const autoR2Buckets = filesystemAdapter === "r2" ? ["STORAGE_R2"] : [];
|
|
165
|
-
const allKVNamespaces = [
|
|
285
|
+
const allKVNamespaces = [...new Set(kvNamespaces)];
|
|
166
286
|
const allR2Buckets = [.../* @__PURE__ */ new Set([...r2Buckets, ...autoR2Buckets])];
|
|
167
287
|
return `# Generated wrangler.toml for Shovel app
|
|
168
288
|
name = "${name}"
|
|
@@ -170,193 +290,28 @@ main = "${entrypoint}"
|
|
|
170
290
|
compatibility_date = "2024-09-23"
|
|
171
291
|
compatibility_flags = ["nodejs_compat"]
|
|
172
292
|
|
|
173
|
-
|
|
174
|
-
usage_model = "bundled"
|
|
175
|
-
|
|
176
|
-
# KV bindings${allKVNamespaces.length > 0 ? "\n" + allKVNamespaces.map(
|
|
177
|
-
(kv) => `[[kv_namespaces]]
|
|
293
|
+
${allKVNamespaces.length > 0 ? allKVNamespaces.map((kv) => `[[kv_namespaces]]
|
|
178
294
|
binding = "${kv}"
|
|
179
|
-
id = "your-kv-
|
|
180
|
-
preview_id = "your-preview-kv-namespace-id"`
|
|
181
|
-
).join("\n\n") : ""}
|
|
295
|
+
id = "your-kv-id"`).join("\n\n") : "# No KV namespaces configured"}
|
|
182
296
|
|
|
183
|
-
|
|
184
|
-
(bucket) => `[[r2_buckets]]
|
|
297
|
+
${allR2Buckets.length > 0 ? allR2Buckets.map((bucket) => `[[r2_buckets]]
|
|
185
298
|
binding = "${bucket}"
|
|
186
|
-
bucket_name = "your-bucket-name"`
|
|
187
|
-
).join("\n\n") : ""}
|
|
299
|
+
bucket_name = "your-bucket-name"`).join("\n\n") : "# No R2 buckets configured"}
|
|
188
300
|
|
|
189
|
-
|
|
190
|
-
${d1Databases.map(
|
|
191
|
-
(db) => `[[d1_databases]]
|
|
301
|
+
${d1Databases.length > 0 ? d1Databases.map((db) => `[[d1_databases]]
|
|
192
302
|
binding = "${db}"
|
|
193
|
-
database_name = "your-
|
|
194
|
-
database_id = "your-
|
|
195
|
-
).join("\n\n")}
|
|
303
|
+
database_name = "your-db-name"
|
|
304
|
+
database_id = "your-db-id"`).join("\n\n") : "# No D1 databases configured"}
|
|
196
305
|
`;
|
|
197
306
|
}
|
|
198
|
-
var cloudflareWorkerBanner = `// Cloudflare Worker ES Module wrapper
|
|
199
|
-
let serviceWorkerGlobals = null;
|
|
200
|
-
|
|
201
|
-
// Capture native Cloudflare caches before any framework code runs
|
|
202
|
-
const nativeCaches = globalThis.caches;
|
|
203
|
-
|
|
204
|
-
// Set up ServiceWorker environment
|
|
205
|
-
if (typeof globalThis.self === 'undefined') {
|
|
206
|
-
globalThis.self = globalThis;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Store native caches for access via globalThis.__cloudflareCaches
|
|
210
|
-
globalThis.__cloudflareCaches = nativeCaches;
|
|
211
|
-
|
|
212
|
-
// Capture fetch event handlers
|
|
213
|
-
const fetchHandlers = [];
|
|
214
|
-
const originalAddEventListener = globalThis.addEventListener;
|
|
215
|
-
globalThis.addEventListener = function(type, handler, options) {
|
|
216
|
-
if (type === 'fetch') {
|
|
217
|
-
fetchHandlers.push(handler);
|
|
218
|
-
} else {
|
|
219
|
-
originalAddEventListener?.call(this, type, handler, options);
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
// Create a promise-based FetchEvent that can be awaited
|
|
224
|
-
class FetchEvent {
|
|
225
|
-
constructor(type, init) {
|
|
226
|
-
this.type = type;
|
|
227
|
-
this.request = init.request;
|
|
228
|
-
this._response = null;
|
|
229
|
-
this._responsePromise = new Promise((resolve) => {
|
|
230
|
-
this._resolveResponse = resolve;
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
respondWith(response) {
|
|
235
|
-
this._response = response;
|
|
236
|
-
this._resolveResponse(response);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async waitUntil(promise) {
|
|
240
|
-
await promise;
|
|
241
|
-
}
|
|
242
|
-
}`;
|
|
243
|
-
var cloudflareWorkerFooter = `
|
|
244
|
-
// Export ES Module for Cloudflare Workers
|
|
245
|
-
export default {
|
|
246
|
-
async fetch(request, env, ctx) {
|
|
247
|
-
try {
|
|
248
|
-
// Set up ServiceWorker-like dirs API for bundled deployment
|
|
249
|
-
if (!globalThis.self.dirs) {
|
|
250
|
-
// For bundled deployment, assets are served via static middleware
|
|
251
|
-
// not through the dirs API
|
|
252
|
-
globalThis.self.dirs = {
|
|
253
|
-
async open(directoryName) {
|
|
254
|
-
if (directoryName === 'assets') {
|
|
255
|
-
// Return a minimal interface that indicates no files available
|
|
256
|
-
// The assets middleware will fall back to dev mode behavior
|
|
257
|
-
return {
|
|
258
|
-
async getFileHandle(fileName) {
|
|
259
|
-
throw new Error(\`NotFoundError: \${fileName} not found in bundled assets\`);
|
|
260
|
-
}
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
throw new Error(\`Directory \${directoryName} not available in bundled deployment\`);
|
|
264
|
-
}
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Set up caches API
|
|
269
|
-
if (!globalThis.self.caches) {
|
|
270
|
-
globalThis.self.caches = globalThis.caches;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// Ensure request.url is a string
|
|
274
|
-
if (typeof request.url !== 'string') {
|
|
275
|
-
return new Response('Invalid request URL: ' + typeof request.url, { status: 500 });
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Create proper FetchEvent-like object
|
|
279
|
-
let responseReceived = null;
|
|
280
|
-
const event = {
|
|
281
|
-
request,
|
|
282
|
-
respondWith: (response) => { responseReceived = response; }
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
// Helper for error responses
|
|
286
|
-
const createErrorResponse = (err) => {
|
|
287
|
-
const isDev = typeof import.meta !== "undefined" && import.meta.env?.MODE !== "production";
|
|
288
|
-
if (isDev) {
|
|
289
|
-
const escapeHtml = (str) => str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
290
|
-
return new Response(\`<!DOCTYPE html>
|
|
291
|
-
<html>
|
|
292
|
-
<head>
|
|
293
|
-
<title>500 Internal Server Error</title>
|
|
294
|
-
<style>
|
|
295
|
-
body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
|
296
|
-
h1 { color: #c00; }
|
|
297
|
-
.message { font-size: 1.2em; color: #333; }
|
|
298
|
-
pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; border-radius: 4px; }
|
|
299
|
-
</style>
|
|
300
|
-
</head>
|
|
301
|
-
<body>
|
|
302
|
-
<h1>500 Internal Server Error</h1>
|
|
303
|
-
<p class="message">\${escapeHtml(err.message)}</p>
|
|
304
|
-
<pre>\${escapeHtml(err.stack || "No stack trace available")}</pre>
|
|
305
|
-
</body>
|
|
306
|
-
</html>\`, { status: 500, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
307
|
-
} else {
|
|
308
|
-
return new Response("Internal Server Error", { status: 500, headers: { "Content-Type": "text/plain" } });
|
|
309
|
-
}
|
|
310
|
-
};
|
|
311
|
-
|
|
312
|
-
// Dispatch to ServiceWorker fetch handlers
|
|
313
|
-
for (const handler of fetchHandlers) {
|
|
314
|
-
try {
|
|
315
|
-
await handler(event);
|
|
316
|
-
if (responseReceived) {
|
|
317
|
-
return responseReceived;
|
|
318
|
-
}
|
|
319
|
-
} catch (error) {
|
|
320
|
-
console.error("Handler error:", error);
|
|
321
|
-
return createErrorResponse(error);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
return new Response('No ServiceWorker handler', { status: 404 });
|
|
326
|
-
} catch (topLevelError) {
|
|
327
|
-
console.error("Top-level error:", topLevelError);
|
|
328
|
-
const isDev = typeof import.meta !== "undefined" && import.meta.env?.MODE !== "production";
|
|
329
|
-
if (isDev) {
|
|
330
|
-
const escapeHtml = (str) => String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
331
|
-
return new Response(\`<!DOCTYPE html>
|
|
332
|
-
<html>
|
|
333
|
-
<head>
|
|
334
|
-
<title>500 Internal Server Error</title>
|
|
335
|
-
<style>
|
|
336
|
-
body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
|
337
|
-
h1 { color: #c00; }
|
|
338
|
-
.message { font-size: 1.2em; color: #333; }
|
|
339
|
-
pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; border-radius: 4px; }
|
|
340
|
-
</style>
|
|
341
|
-
</head>
|
|
342
|
-
<body>
|
|
343
|
-
<h1>500 Internal Server Error</h1>
|
|
344
|
-
<p class="message">\${escapeHtml(topLevelError.message)}</p>
|
|
345
|
-
<pre>\${escapeHtml(topLevelError.stack || "No stack trace available")}</pre>
|
|
346
|
-
</body>
|
|
347
|
-
</html>\`, { status: 500, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
348
|
-
} else {
|
|
349
|
-
return new Response("Internal Server Error", { status: 500, headers: { "Content-Type": "text/plain" } });
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
};`;
|
|
354
307
|
var src_default = CloudflarePlatform;
|
|
355
308
|
export {
|
|
356
309
|
CloudflarePlatform,
|
|
357
|
-
|
|
358
|
-
cloudflareWorkerFooter,
|
|
310
|
+
createFetchHandler,
|
|
359
311
|
createOptionsFromEnv,
|
|
360
312
|
src_default as default,
|
|
361
|
-
generateWranglerConfig
|
|
313
|
+
generateWranglerConfig,
|
|
314
|
+
getCtx,
|
|
315
|
+
getEnv,
|
|
316
|
+
initializeRuntime
|
|
362
317
|
};
|