@solcreek/cli 0.3.1 → 0.3.3
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/dist/commands/dev.d.ts +22 -0
- package/dist/commands/dev.js +63 -0
- package/dist/dev/dev-proxy.d.ts +26 -0
- package/dist/dev/dev-proxy.js +134 -0
- package/dist/dev/local-realtime.d.ts +40 -0
- package/dist/dev/local-realtime.js +242 -0
- package/dist/dev/ports.d.ts +5 -0
- package/dist/dev/ports.js +34 -0
- package/dist/dev/server.d.ts +18 -0
- package/dist/dev/server.js +143 -0
- package/dist/dev/vite-bridge.d.ts +15 -0
- package/dist/dev/vite-bridge.js +42 -0
- package/dist/dev/worker-runner.d.ts +61 -0
- package/dist/dev/worker-runner.js +203 -0
- package/dist/index.js +16 -2
- package/dist/utils/worker-bundle.js +56 -57
- package/package.json +14 -10
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const devCommand: import("citty").CommandDef<{
|
|
2
|
+
port: {
|
|
3
|
+
type: "string";
|
|
4
|
+
description: string;
|
|
5
|
+
default: string;
|
|
6
|
+
};
|
|
7
|
+
reset: {
|
|
8
|
+
type: "boolean";
|
|
9
|
+
description: string;
|
|
10
|
+
};
|
|
11
|
+
json: {
|
|
12
|
+
type: "boolean";
|
|
13
|
+
description: string;
|
|
14
|
+
default: boolean;
|
|
15
|
+
};
|
|
16
|
+
yes: {
|
|
17
|
+
type: "boolean";
|
|
18
|
+
description: string;
|
|
19
|
+
default: boolean;
|
|
20
|
+
};
|
|
21
|
+
}>;
|
|
22
|
+
//# sourceMappingURL=dev.d.ts.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { consola } from "consola";
|
|
3
|
+
import { resolveConfig, formatDetectionSummary } from "@solcreek/sdk";
|
|
4
|
+
import { globalArgs } from "../utils/output.js";
|
|
5
|
+
import { DevServer } from "../dev/server.js";
|
|
6
|
+
export const devCommand = defineCommand({
|
|
7
|
+
meta: {
|
|
8
|
+
name: "dev",
|
|
9
|
+
description: "Start local development server",
|
|
10
|
+
},
|
|
11
|
+
args: {
|
|
12
|
+
...globalArgs,
|
|
13
|
+
port: {
|
|
14
|
+
type: "string",
|
|
15
|
+
description: "Port number (default: 3000)",
|
|
16
|
+
default: "3000",
|
|
17
|
+
},
|
|
18
|
+
reset: {
|
|
19
|
+
type: "boolean",
|
|
20
|
+
description: "Clear local data before starting",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
async run({ args }) {
|
|
24
|
+
const cwd = process.cwd();
|
|
25
|
+
let config;
|
|
26
|
+
try {
|
|
27
|
+
config = resolveConfig(cwd);
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
consola.error(e.message);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
const port = parseInt(args.port, 10);
|
|
34
|
+
if (isNaN(port) || port < 0 || port > 65535) {
|
|
35
|
+
consola.error(`Invalid port: ${args.port}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
consola.info(`Detected: ${formatDetectionSummary(config)}`);
|
|
39
|
+
const server = new DevServer({
|
|
40
|
+
cwd,
|
|
41
|
+
port,
|
|
42
|
+
config,
|
|
43
|
+
reset: !!args.reset,
|
|
44
|
+
});
|
|
45
|
+
// Graceful shutdown
|
|
46
|
+
const shutdown = async () => {
|
|
47
|
+
consola.info("Shutting down...");
|
|
48
|
+
await server.stop();
|
|
49
|
+
process.exit(0);
|
|
50
|
+
};
|
|
51
|
+
process.on("SIGINT", shutdown);
|
|
52
|
+
process.on("SIGTERM", shutdown);
|
|
53
|
+
try {
|
|
54
|
+
await server.start();
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
consola.error(`Failed to start dev server: ${e.message}`);
|
|
58
|
+
await server.stop();
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
//# sourceMappingURL=dev.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { type LocalRealtimeServer } from "./local-realtime.js";
|
|
3
|
+
type ConnectServer = (req: IncomingMessage, res: ServerResponse, next: () => void) => void;
|
|
4
|
+
export interface DevProxyOptions {
|
|
5
|
+
/** User-facing port. */
|
|
6
|
+
port: number;
|
|
7
|
+
/** Miniflare worker URL (e.g. "http://127.0.0.1:8787"). Null if no worker. */
|
|
8
|
+
workerUrl: string | null;
|
|
9
|
+
/** Vite middleware. Null if no Vite (worker-only project). */
|
|
10
|
+
viteMiddleware: ConnectServer | null;
|
|
11
|
+
/** Local realtime server instance. */
|
|
12
|
+
realtimeServer: LocalRealtimeServer;
|
|
13
|
+
/** Project slug. */
|
|
14
|
+
projectSlug: string;
|
|
15
|
+
}
|
|
16
|
+
export declare class DevProxy {
|
|
17
|
+
private server;
|
|
18
|
+
private options;
|
|
19
|
+
constructor(options: DevProxyOptions);
|
|
20
|
+
start(): Promise<void>;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
/** Get the underlying HTTP server (for Vite HMR upgrade). */
|
|
23
|
+
getServer(): Server | null;
|
|
24
|
+
}
|
|
25
|
+
export {};
|
|
26
|
+
//# sourceMappingURL=dev-proxy.d.ts.map
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// Dev proxy server for `creek dev`.
|
|
2
|
+
//
|
|
3
|
+
// Single user-facing HTTP server that routes:
|
|
4
|
+
// /__creek/config → local config response
|
|
5
|
+
// /{slug}/**/broadcast → realtime server (HTTP)
|
|
6
|
+
// /{slug}/**/ws → realtime server (WebSocket upgrade)
|
|
7
|
+
// /api/*, /__creek/* → worker (Miniflare)
|
|
8
|
+
// everything else → Vite (HMR) or worker (if no Vite)
|
|
9
|
+
import { createServer, request as httpRequest, } from "node:http";
|
|
10
|
+
import { parseRealtimePath, } from "./local-realtime.js";
|
|
11
|
+
export class DevProxy {
|
|
12
|
+
server = null;
|
|
13
|
+
options;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.options = options;
|
|
16
|
+
}
|
|
17
|
+
async start() {
|
|
18
|
+
const { port, workerUrl, viteMiddleware, realtimeServer, projectSlug } = this.options;
|
|
19
|
+
this.server = createServer((req, res) => {
|
|
20
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
21
|
+
// 1. /__creek/config → local config
|
|
22
|
+
if (url.pathname === "/__creek/config" && req.method === "GET") {
|
|
23
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
24
|
+
res.end(JSON.stringify({
|
|
25
|
+
realtimeUrl: `http://localhost:${port}`,
|
|
26
|
+
projectSlug,
|
|
27
|
+
wsToken: null,
|
|
28
|
+
}));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// 2. Realtime broadcast: POST /{slug}/**/broadcast
|
|
32
|
+
const route = parseRealtimePath(url.pathname);
|
|
33
|
+
if (route && route.action === "/broadcast" && req.method === "POST") {
|
|
34
|
+
realtimeServer.handleBroadcast(req, res);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
// 3. Realtime status: GET /{slug}/**/status
|
|
38
|
+
if (route && route.action === "/status" && req.method === "GET") {
|
|
39
|
+
realtimeServer.handleStatus(req, res);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
// 4. API/worker routes → proxy to Miniflare
|
|
43
|
+
if (workerUrl && isWorkerRoute(url.pathname)) {
|
|
44
|
+
proxyToWorker(req, res, workerUrl);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// 5. Everything else → Vite middleware or worker fallback
|
|
48
|
+
if (viteMiddleware) {
|
|
49
|
+
viteMiddleware(req, res, () => {
|
|
50
|
+
// If Vite doesn't handle it and we have a worker, try worker
|
|
51
|
+
if (workerUrl) {
|
|
52
|
+
proxyToWorker(req, res, workerUrl);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
res.writeHead(404);
|
|
56
|
+
res.end("Not Found");
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// No Vite — all traffic to worker
|
|
62
|
+
if (workerUrl) {
|
|
63
|
+
proxyToWorker(req, res, workerUrl);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
res.writeHead(404);
|
|
67
|
+
res.end("Not Found");
|
|
68
|
+
});
|
|
69
|
+
// WebSocket upgrade routing
|
|
70
|
+
this.server.on("upgrade", (req, socket, head) => {
|
|
71
|
+
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
72
|
+
const route = parseRealtimePath(url.pathname);
|
|
73
|
+
// Realtime WebSocket: /{slug}/**/ws
|
|
74
|
+
if (route && route.action === "/ws") {
|
|
75
|
+
realtimeServer.handleUpgrade(req, socket, head);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// Vite HMR WebSocket — pass through to Vite middleware
|
|
79
|
+
if (viteMiddleware) {
|
|
80
|
+
// Vite's middleware handles WebSocket upgrades via the http server's
|
|
81
|
+
// upgrade event. We need to let Vite handle it. Vite listens on the
|
|
82
|
+
// same server's upgrade event, so we should NOT destroy the socket.
|
|
83
|
+
// The Vite server is in middleware mode and registered its own
|
|
84
|
+
// upgrade handler already. We just don't interfere.
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
socket.destroy();
|
|
88
|
+
});
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
this.server.on("error", reject);
|
|
91
|
+
this.server.listen(port, "127.0.0.1", () => resolve());
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
async stop() {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
if (this.server) {
|
|
97
|
+
this.server.close(() => resolve());
|
|
98
|
+
this.server = null;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
resolve();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/** Get the underlying HTTP server (for Vite HMR upgrade). */
|
|
106
|
+
getServer() {
|
|
107
|
+
return this.server;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
111
|
+
function isWorkerRoute(pathname) {
|
|
112
|
+
return (pathname.startsWith("/api/") ||
|
|
113
|
+
pathname.startsWith("/__creek/") ||
|
|
114
|
+
pathname === "/__creek");
|
|
115
|
+
}
|
|
116
|
+
function proxyToWorker(req, res, workerUrl) {
|
|
117
|
+
const { hostname, port } = new URL(workerUrl);
|
|
118
|
+
const proxyReq = httpRequest({
|
|
119
|
+
hostname,
|
|
120
|
+
port,
|
|
121
|
+
path: req.url,
|
|
122
|
+
method: req.method,
|
|
123
|
+
headers: req.headers,
|
|
124
|
+
}, (proxyRes) => {
|
|
125
|
+
res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
|
|
126
|
+
proxyRes.pipe(res);
|
|
127
|
+
});
|
|
128
|
+
proxyReq.on("error", () => {
|
|
129
|
+
res.writeHead(502);
|
|
130
|
+
res.end("Worker unavailable");
|
|
131
|
+
});
|
|
132
|
+
req.pipe(proxyReq);
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=dev-proxy.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
export interface RealtimeRoute {
|
|
4
|
+
slug: string;
|
|
5
|
+
roomId: string | null;
|
|
6
|
+
action: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function parseRealtimePath(pathname: string): RealtimeRoute | null;
|
|
9
|
+
export declare function getDoName(route: RealtimeRoute): string;
|
|
10
|
+
export declare class LocalRealtimeServer {
|
|
11
|
+
private httpServer;
|
|
12
|
+
private wss;
|
|
13
|
+
private rooms;
|
|
14
|
+
private port;
|
|
15
|
+
constructor(options?: {
|
|
16
|
+
port?: number;
|
|
17
|
+
});
|
|
18
|
+
start(): Promise<{
|
|
19
|
+
port: number;
|
|
20
|
+
}>;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
/** Broadcast a message to all connected clients in a room. */
|
|
23
|
+
broadcast(roomKey: string, message: object): void;
|
|
24
|
+
/** Get the number of connected clients in a room. */
|
|
25
|
+
getRoomCount(roomKey: string): number;
|
|
26
|
+
/** Get the port the server is listening on. */
|
|
27
|
+
getPort(): number;
|
|
28
|
+
/** @internal — inject a mock WebSocket into a room for testing. */
|
|
29
|
+
_testAddSocket(roomKey: string, ws: WebSocket): void;
|
|
30
|
+
/** Handle WebSocket upgrade from an external HTTP server. */
|
|
31
|
+
handleUpgrade(req: IncomingMessage, socket: import("node:net").Socket, head: Buffer): void;
|
|
32
|
+
/** Handle broadcast POST from an external HTTP server. */
|
|
33
|
+
handleBroadcast(req: IncomingMessage, res: ServerResponse): void;
|
|
34
|
+
/** Handle status GET from an external HTTP server. */
|
|
35
|
+
handleStatus(req: IncomingMessage, res: ServerResponse): void;
|
|
36
|
+
private addToRoom;
|
|
37
|
+
private broadcastPeers;
|
|
38
|
+
private handleHttp;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=local-realtime.d.ts.map
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Local realtime server for `creek dev`.
|
|
2
|
+
//
|
|
3
|
+
// Replaces the production Durable Object realtime service (rt.creek.dev)
|
|
4
|
+
// with a lightweight Node.js WebSocket server. Same URL routing and
|
|
5
|
+
// message format — client code works unchanged.
|
|
6
|
+
import { createServer, } from "node:http";
|
|
7
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
8
|
+
const VALID_ACTIONS = new Set(["/broadcast", "/ws", "/status"]);
|
|
9
|
+
export function parseRealtimePath(pathname) {
|
|
10
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
11
|
+
if (parts.length < 2)
|
|
12
|
+
return null;
|
|
13
|
+
const slug = parts[0];
|
|
14
|
+
// Room-scoped: /{slug}/rooms/{roomId}/{action}
|
|
15
|
+
if (parts[1] === "rooms") {
|
|
16
|
+
if (parts.length < 4)
|
|
17
|
+
return null;
|
|
18
|
+
const roomId = parts[2];
|
|
19
|
+
const action = "/" + parts[3];
|
|
20
|
+
if (!VALID_ACTIONS.has(action))
|
|
21
|
+
return null;
|
|
22
|
+
if (!roomId)
|
|
23
|
+
return null;
|
|
24
|
+
return { slug, roomId, action };
|
|
25
|
+
}
|
|
26
|
+
// Project-wide (legacy): /{slug}/{action}
|
|
27
|
+
const action = "/" + parts.slice(1).join("/");
|
|
28
|
+
if (!VALID_ACTIONS.has(action))
|
|
29
|
+
return null;
|
|
30
|
+
return { slug, roomId: null, action };
|
|
31
|
+
}
|
|
32
|
+
export function getDoName(route) {
|
|
33
|
+
return route.roomId ? `${route.slug}:${route.roomId}` : route.slug;
|
|
34
|
+
}
|
|
35
|
+
// ─── LocalRealtimeServer ──────────────────────────────────────────────────────
|
|
36
|
+
export class LocalRealtimeServer {
|
|
37
|
+
httpServer = null;
|
|
38
|
+
wss = null;
|
|
39
|
+
rooms = new Map();
|
|
40
|
+
port;
|
|
41
|
+
constructor(options) {
|
|
42
|
+
this.port = options?.port ?? 0; // 0 = OS auto-assign
|
|
43
|
+
}
|
|
44
|
+
async start() {
|
|
45
|
+
this.httpServer = createServer((req, res) => this.handleHttp(req, res));
|
|
46
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
47
|
+
this.httpServer.on("upgrade", (req, socket, head) => {
|
|
48
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
49
|
+
const route = parseRealtimePath(url.pathname);
|
|
50
|
+
if (!route || route.action !== "/ws") {
|
|
51
|
+
socket.destroy();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
55
|
+
const roomKey = getDoName(route);
|
|
56
|
+
this.addToRoom(roomKey, ws);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
this.httpServer.on("error", reject);
|
|
61
|
+
this.httpServer.listen(this.port, "127.0.0.1", () => {
|
|
62
|
+
const addr = this.httpServer.address();
|
|
63
|
+
if (typeof addr === "object" && addr) {
|
|
64
|
+
this.port = addr.port;
|
|
65
|
+
}
|
|
66
|
+
resolve({ port: this.port });
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async stop() {
|
|
71
|
+
// Close all WebSocket connections
|
|
72
|
+
for (const [, room] of this.rooms) {
|
|
73
|
+
for (const ws of room) {
|
|
74
|
+
ws.close(1001, "Server shutting down");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
this.rooms.clear();
|
|
78
|
+
// Close WebSocket server
|
|
79
|
+
if (this.wss) {
|
|
80
|
+
this.wss.close();
|
|
81
|
+
this.wss = null;
|
|
82
|
+
}
|
|
83
|
+
// Close HTTP server
|
|
84
|
+
return new Promise((resolve) => {
|
|
85
|
+
if (this.httpServer) {
|
|
86
|
+
this.httpServer.close(() => resolve());
|
|
87
|
+
this.httpServer = null;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
resolve();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** Broadcast a message to all connected clients in a room. */
|
|
95
|
+
broadcast(roomKey, message) {
|
|
96
|
+
const room = this.rooms.get(roomKey);
|
|
97
|
+
if (!room)
|
|
98
|
+
return;
|
|
99
|
+
const data = JSON.stringify(message);
|
|
100
|
+
for (const ws of room) {
|
|
101
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
102
|
+
ws.send(data);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Get the number of connected clients in a room. */
|
|
107
|
+
getRoomCount(roomKey) {
|
|
108
|
+
return this.rooms.get(roomKey)?.size ?? 0;
|
|
109
|
+
}
|
|
110
|
+
/** Get the port the server is listening on. */
|
|
111
|
+
getPort() {
|
|
112
|
+
return this.port;
|
|
113
|
+
}
|
|
114
|
+
/** @internal — inject a mock WebSocket into a room for testing. */
|
|
115
|
+
_testAddSocket(roomKey, ws) {
|
|
116
|
+
let room = this.rooms.get(roomKey);
|
|
117
|
+
if (!room) {
|
|
118
|
+
room = new Set();
|
|
119
|
+
this.rooms.set(roomKey, room);
|
|
120
|
+
}
|
|
121
|
+
room.add(ws);
|
|
122
|
+
}
|
|
123
|
+
// ─── Public handlers (for DevProxy integration) ────────────────────────────
|
|
124
|
+
/** Handle WebSocket upgrade from an external HTTP server. */
|
|
125
|
+
handleUpgrade(req, socket, head) {
|
|
126
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
127
|
+
const route = parseRealtimePath(url.pathname);
|
|
128
|
+
if (!route || route.action !== "/ws") {
|
|
129
|
+
socket.destroy();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!this.wss) {
|
|
133
|
+
// Lazy-init WSS if server not started standalone
|
|
134
|
+
this.wss = new WebSocketServer({ noServer: true });
|
|
135
|
+
}
|
|
136
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => {
|
|
137
|
+
const roomKey = getDoName(route);
|
|
138
|
+
this.addToRoom(roomKey, ws);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** Handle broadcast POST from an external HTTP server. */
|
|
142
|
+
handleBroadcast(req, res) {
|
|
143
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
144
|
+
const route = parseRealtimePath(url.pathname);
|
|
145
|
+
if (!route || route.action !== "/broadcast" || req.method !== "POST") {
|
|
146
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
147
|
+
res.end(JSON.stringify({ error: "Not Found" }));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
let body = "";
|
|
151
|
+
req.on("data", (chunk) => {
|
|
152
|
+
body += chunk.toString();
|
|
153
|
+
});
|
|
154
|
+
req.on("end", () => {
|
|
155
|
+
let event;
|
|
156
|
+
try {
|
|
157
|
+
event = JSON.parse(body);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
161
|
+
res.end(JSON.stringify({ error: "invalid JSON" }));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const roomKey = getDoName(route);
|
|
165
|
+
this.broadcast(roomKey, {
|
|
166
|
+
type: "db_changed",
|
|
167
|
+
table: event.table ?? "unknown",
|
|
168
|
+
operation: event.operation ?? "UNKNOWN",
|
|
169
|
+
});
|
|
170
|
+
const count = this.rooms.get(roomKey)?.size ?? 0;
|
|
171
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
172
|
+
res.end(JSON.stringify({ ok: true, clients: count }));
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/** Handle status GET from an external HTTP server. */
|
|
176
|
+
handleStatus(req, res) {
|
|
177
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
178
|
+
const route = parseRealtimePath(url.pathname);
|
|
179
|
+
if (!route) {
|
|
180
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
181
|
+
res.end(JSON.stringify({ error: "Not Found" }));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const roomKey = getDoName(route);
|
|
185
|
+
const count = this.rooms.get(roomKey)?.size ?? 0;
|
|
186
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
187
|
+
res.end(JSON.stringify({ clients: count }));
|
|
188
|
+
}
|
|
189
|
+
// ─── Internal ───────────────────────────────────────────────────────────────
|
|
190
|
+
addToRoom(roomKey, ws) {
|
|
191
|
+
let room = this.rooms.get(roomKey);
|
|
192
|
+
if (!room) {
|
|
193
|
+
room = new Set();
|
|
194
|
+
this.rooms.set(roomKey, room);
|
|
195
|
+
}
|
|
196
|
+
room.add(ws);
|
|
197
|
+
this.broadcastPeers(roomKey);
|
|
198
|
+
ws.on("close", () => {
|
|
199
|
+
room.delete(ws);
|
|
200
|
+
if (room.size === 0)
|
|
201
|
+
this.rooms.delete(roomKey);
|
|
202
|
+
this.broadcastPeers(roomKey);
|
|
203
|
+
});
|
|
204
|
+
ws.on("error", () => {
|
|
205
|
+
room.delete(ws);
|
|
206
|
+
if (room.size === 0)
|
|
207
|
+
this.rooms.delete(roomKey);
|
|
208
|
+
this.broadcastPeers(roomKey);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
broadcastPeers(roomKey) {
|
|
212
|
+
const count = this.rooms.get(roomKey)?.size ?? 0;
|
|
213
|
+
this.broadcast(roomKey, { type: "peers", count });
|
|
214
|
+
}
|
|
215
|
+
handleHttp(req, res) {
|
|
216
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
217
|
+
// Root health check
|
|
218
|
+
if (url.pathname === "/" || url.pathname === "") {
|
|
219
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
220
|
+
res.end(JSON.stringify({ service: "creek-realtime-local", status: "ok" }));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const route = parseRealtimePath(url.pathname);
|
|
224
|
+
if (!route) {
|
|
225
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
226
|
+
res.end(JSON.stringify({ error: "Not Found" }));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// Delegate to public handlers
|
|
230
|
+
if (route.action === "/broadcast" && req.method === "POST") {
|
|
231
|
+
this.handleBroadcast(req, res);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (route.action === "/status" && req.method === "GET") {
|
|
235
|
+
this.handleStatus(req, res);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
239
|
+
res.end(JSON.stringify({ error: "Not Found" }));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
//# sourceMappingURL=local-realtime.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Check if a port is available on localhost. */
|
|
2
|
+
export declare function isPortAvailable(port: number): Promise<boolean>;
|
|
3
|
+
/** Find an available port, starting from the preferred port. */
|
|
4
|
+
export declare function findAvailablePort(preferred?: number): Promise<number>;
|
|
5
|
+
//# sourceMappingURL=ports.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Port allocation utilities for `creek dev`.
|
|
2
|
+
import { createServer } from "node:net";
|
|
3
|
+
/** Check if a port is available on localhost. */
|
|
4
|
+
export function isPortAvailable(port) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
const server = createServer();
|
|
7
|
+
server.once("error", () => resolve(false));
|
|
8
|
+
server.listen(port, "127.0.0.1", () => {
|
|
9
|
+
server.close(() => resolve(true));
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/** Find an available port, starting from the preferred port. */
|
|
14
|
+
export async function findAvailablePort(preferred = 3000) {
|
|
15
|
+
if (await isPortAvailable(preferred))
|
|
16
|
+
return preferred;
|
|
17
|
+
// Try a few ports around the preferred one
|
|
18
|
+
for (let offset = 1; offset <= 10; offset++) {
|
|
19
|
+
const port = preferred + offset;
|
|
20
|
+
if (await isPortAvailable(port))
|
|
21
|
+
return port;
|
|
22
|
+
}
|
|
23
|
+
// Fall back to OS-assigned port
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const server = createServer();
|
|
26
|
+
server.once("error", reject);
|
|
27
|
+
server.listen(0, "127.0.0.1", () => {
|
|
28
|
+
const addr = server.address();
|
|
29
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
30
|
+
server.close(() => resolve(port));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=ports.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "@solcreek/sdk";
|
|
2
|
+
export interface DevServerOptions {
|
|
3
|
+
cwd: string;
|
|
4
|
+
port: number;
|
|
5
|
+
config: ResolvedConfig;
|
|
6
|
+
reset: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare class DevServer {
|
|
9
|
+
private options;
|
|
10
|
+
private realtimeServer;
|
|
11
|
+
private workerRunner;
|
|
12
|
+
private viteBridge;
|
|
13
|
+
private proxy;
|
|
14
|
+
constructor(options: DevServerOptions);
|
|
15
|
+
start(): Promise<void>;
|
|
16
|
+
stop(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// DevServer orchestrator for `creek dev`.
|
|
2
|
+
//
|
|
3
|
+
// Manages the lifecycle of all subsystems:
|
|
4
|
+
// 1. LocalRealtimeServer — WebSocket broadcast
|
|
5
|
+
// 2. WorkerRunner — Miniflare with D1/KV/R2
|
|
6
|
+
// 3. ViteBridge — Client-side HMR
|
|
7
|
+
// 4. DevProxy — User-facing HTTP server
|
|
8
|
+
import { existsSync, rmSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { consola } from "consola";
|
|
11
|
+
import { LocalRealtimeServer } from "./local-realtime.js";
|
|
12
|
+
import { WorkerRunner } from "./worker-runner.js";
|
|
13
|
+
import { ViteBridge } from "./vite-bridge.js";
|
|
14
|
+
import { DevProxy } from "./dev-proxy.js";
|
|
15
|
+
import { findAvailablePort } from "./ports.js";
|
|
16
|
+
export class DevServer {
|
|
17
|
+
options;
|
|
18
|
+
realtimeServer = null;
|
|
19
|
+
workerRunner = null;
|
|
20
|
+
viteBridge = null;
|
|
21
|
+
proxy = null;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
}
|
|
25
|
+
async start() {
|
|
26
|
+
const { cwd, port, config, reset } = this.options;
|
|
27
|
+
const persistDir = join(cwd, ".creek", "dev");
|
|
28
|
+
const startTime = Date.now();
|
|
29
|
+
// 1. Handle --reset
|
|
30
|
+
if (reset && existsSync(persistDir)) {
|
|
31
|
+
rmSync(persistDir, { recursive: true, force: true });
|
|
32
|
+
consola.info("Cleared local data");
|
|
33
|
+
}
|
|
34
|
+
// Create persistence directory
|
|
35
|
+
mkdirSync(persistDir, { recursive: true });
|
|
36
|
+
// 2. Start realtime server
|
|
37
|
+
this.realtimeServer = new LocalRealtimeServer({ port: 0 });
|
|
38
|
+
await this.realtimeServer.start();
|
|
39
|
+
// 3. Start worker (if project has a worker entry)
|
|
40
|
+
let workerUrl = null;
|
|
41
|
+
if (config.workerEntry) {
|
|
42
|
+
this.workerRunner = new WorkerRunner({
|
|
43
|
+
entryPoint: config.workerEntry,
|
|
44
|
+
cwd,
|
|
45
|
+
bindings: config.bindings,
|
|
46
|
+
persistDir,
|
|
47
|
+
realtimeUrl: `http://127.0.0.1:${this.realtimeServer.getPort()}`,
|
|
48
|
+
projectSlug: config.projectName,
|
|
49
|
+
vars: config.vars,
|
|
50
|
+
onRebuild: (ms) => {
|
|
51
|
+
consola.info(`Worker rebuilt in ${ms}ms`);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const { port: workerPort } = await this.workerRunner.start();
|
|
55
|
+
workerUrl = `http://127.0.0.1:${workerPort}`;
|
|
56
|
+
}
|
|
57
|
+
// 4. Start Vite (if project has a frontend framework)
|
|
58
|
+
let viteMiddleware = null;
|
|
59
|
+
const hasFramework = config.framework !== null;
|
|
60
|
+
if (hasFramework) {
|
|
61
|
+
this.viteBridge = new ViteBridge({ cwd });
|
|
62
|
+
try {
|
|
63
|
+
await this.viteBridge.start();
|
|
64
|
+
viteMiddleware = this.viteBridge.middlewares;
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
if (e.message?.includes("Vite is not installed")) {
|
|
68
|
+
consola.warn(e.message);
|
|
69
|
+
this.viteBridge = null;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// 5. Resolve port (auto-find if occupied)
|
|
77
|
+
const actualPort = await findAvailablePort(port);
|
|
78
|
+
if (actualPort !== port) {
|
|
79
|
+
consola.warn(`Port ${port} is in use, using ${actualPort} instead`);
|
|
80
|
+
}
|
|
81
|
+
// 6. Start proxy
|
|
82
|
+
this.proxy = new DevProxy({
|
|
83
|
+
port: actualPort,
|
|
84
|
+
workerUrl,
|
|
85
|
+
viteMiddleware,
|
|
86
|
+
realtimeServer: this.realtimeServer,
|
|
87
|
+
projectSlug: config.projectName,
|
|
88
|
+
});
|
|
89
|
+
await this.proxy.start();
|
|
90
|
+
// 7. Wire Vite's HMR to the proxy's HTTP server
|
|
91
|
+
if (this.viteBridge && this.proxy.getServer()) {
|
|
92
|
+
const httpServer = this.proxy.getServer();
|
|
93
|
+
// Vite's middleware mode needs to handle upgrade events
|
|
94
|
+
// from our proxy server for HMR WebSocket
|
|
95
|
+
const viteWss = this.viteBridge.viteServer?.ws;
|
|
96
|
+
if (viteWss) {
|
|
97
|
+
httpServer.on("upgrade", (req, socket, head) => {
|
|
98
|
+
// Only handle Vite's HMR paths
|
|
99
|
+
if (req.url?.startsWith("/__vite") ||
|
|
100
|
+
req.url?.startsWith("/@vite")) {
|
|
101
|
+
viteWss.handleUpgrade(req, socket, head);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 8. Print status
|
|
107
|
+
const elapsed = Date.now() - startTime;
|
|
108
|
+
const bindings = config.bindings
|
|
109
|
+
.filter((b) => ["d1", "kv", "r2", "ai"].includes(b.type))
|
|
110
|
+
.map((b) => b.type.toUpperCase());
|
|
111
|
+
const bindingStr = bindings.length > 0 ? ` (${bindings.join(", ")})` : "";
|
|
112
|
+
console.log("");
|
|
113
|
+
consola.success(`creek dev\n`);
|
|
114
|
+
consola.info(`App: http://localhost:${actualPort}`);
|
|
115
|
+
if (config.workerEntry) {
|
|
116
|
+
consola.info(`Worker: ${config.workerEntry}${bindingStr}`);
|
|
117
|
+
}
|
|
118
|
+
consola.info(`Realtime: ws://localhost:${actualPort}`);
|
|
119
|
+
consola.info(`Data: .creek/dev/`);
|
|
120
|
+
console.log("");
|
|
121
|
+
consola.success(`Ready in ${elapsed}ms`);
|
|
122
|
+
}
|
|
123
|
+
async stop() {
|
|
124
|
+
// Stop in reverse order
|
|
125
|
+
if (this.proxy) {
|
|
126
|
+
await this.proxy.stop();
|
|
127
|
+
this.proxy = null;
|
|
128
|
+
}
|
|
129
|
+
if (this.viteBridge) {
|
|
130
|
+
await this.viteBridge.stop();
|
|
131
|
+
this.viteBridge = null;
|
|
132
|
+
}
|
|
133
|
+
if (this.workerRunner) {
|
|
134
|
+
await this.workerRunner.stop();
|
|
135
|
+
this.workerRunner = null;
|
|
136
|
+
}
|
|
137
|
+
if (this.realtimeServer) {
|
|
138
|
+
await this.realtimeServer.stop();
|
|
139
|
+
this.realtimeServer = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
type ConnectMiddleware = (req: IncomingMessage, res: ServerResponse, next: () => void) => void;
|
|
3
|
+
export declare class ViteBridge {
|
|
4
|
+
private viteServer;
|
|
5
|
+
private cwd;
|
|
6
|
+
constructor(options: {
|
|
7
|
+
cwd: string;
|
|
8
|
+
});
|
|
9
|
+
start(): Promise<void>;
|
|
10
|
+
stop(): Promise<void>;
|
|
11
|
+
/** Vite's Connect middleware stack — plug into the proxy server. */
|
|
12
|
+
get middlewares(): ConnectMiddleware;
|
|
13
|
+
}
|
|
14
|
+
export {};
|
|
15
|
+
//# sourceMappingURL=vite-bridge.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Vite dev server integration for `creek dev`.
|
|
2
|
+
//
|
|
3
|
+
// Uses Vite's middleware mode — no separate HTTP server.
|
|
4
|
+
// HMR WebSocket is handled through Vite's own middleware.
|
|
5
|
+
export class ViteBridge {
|
|
6
|
+
viteServer = null; // ViteDevServer — dynamically imported
|
|
7
|
+
cwd;
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.cwd = options.cwd;
|
|
10
|
+
}
|
|
11
|
+
async start() {
|
|
12
|
+
let vite;
|
|
13
|
+
try {
|
|
14
|
+
vite = await import("vite");
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error("[creek dev] Vite is not installed. Run `npm install -D vite` to enable client-side HMR.");
|
|
18
|
+
}
|
|
19
|
+
this.viteServer = await vite.createServer({
|
|
20
|
+
root: this.cwd,
|
|
21
|
+
server: {
|
|
22
|
+
middlewareMode: true,
|
|
23
|
+
hmr: true,
|
|
24
|
+
},
|
|
25
|
+
appType: "spa",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
async stop() {
|
|
29
|
+
if (this.viteServer) {
|
|
30
|
+
await this.viteServer.close();
|
|
31
|
+
this.viteServer = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Vite's Connect middleware stack — plug into the proxy server. */
|
|
35
|
+
get middlewares() {
|
|
36
|
+
if (!this.viteServer) {
|
|
37
|
+
throw new Error("ViteBridge not started");
|
|
38
|
+
}
|
|
39
|
+
return this.viteServer.middlewares;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=vite-bridge.js.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { BindingDeclaration } from "@solcreek/sdk";
|
|
2
|
+
export interface WorkerRunnerOptions {
|
|
3
|
+
/** User's worker entry point (e.g. "worker/index.ts"). */
|
|
4
|
+
entryPoint: string;
|
|
5
|
+
/** Project root directory. */
|
|
6
|
+
cwd: string;
|
|
7
|
+
/** Declared bindings from creek.toml / wrangler config. */
|
|
8
|
+
bindings: BindingDeclaration[];
|
|
9
|
+
/** Persistence directory for D1/KV/R2 data. */
|
|
10
|
+
persistDir: string;
|
|
11
|
+
/** Local realtime server URL (e.g. "http://localhost:8788"). */
|
|
12
|
+
realtimeUrl: string;
|
|
13
|
+
/** Project slug (e.g. "realtime-todos"). */
|
|
14
|
+
projectSlug: string;
|
|
15
|
+
/** User-defined environment variables from config. */
|
|
16
|
+
vars?: Record<string, string>;
|
|
17
|
+
/** Whether the project has client assets (Worker + SPA hybrid). */
|
|
18
|
+
hasClientAssets?: boolean;
|
|
19
|
+
/** Callback when worker is rebuilt. */
|
|
20
|
+
onRebuild?: (durationMs: number) => void;
|
|
21
|
+
/**
|
|
22
|
+
* Additional node module resolution paths for esbuild.
|
|
23
|
+
* @internal — used by tests to resolve `creek` from monorepo.
|
|
24
|
+
*/
|
|
25
|
+
nodePaths?: string[];
|
|
26
|
+
}
|
|
27
|
+
export declare class WorkerRunner {
|
|
28
|
+
private mf;
|
|
29
|
+
private esbuildCtx;
|
|
30
|
+
private options;
|
|
31
|
+
private mfOptions;
|
|
32
|
+
private port;
|
|
33
|
+
constructor(options: WorkerRunnerOptions);
|
|
34
|
+
start(): Promise<{
|
|
35
|
+
port: number;
|
|
36
|
+
}>;
|
|
37
|
+
stop(): Promise<void>;
|
|
38
|
+
/** Get the URL the worker is running on. */
|
|
39
|
+
getUrl(): string;
|
|
40
|
+
/** Get the port the worker is running on. */
|
|
41
|
+
getPort(): number;
|
|
42
|
+
/** Dispatch a fetch request to the worker (via Miniflare). */
|
|
43
|
+
dispatchFetch(input: string, init?: RequestInit): Promise<Response>;
|
|
44
|
+
private buildMiniflareOptions;
|
|
45
|
+
private get esbuildOptions();
|
|
46
|
+
private bundle;
|
|
47
|
+
private startWatching;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Build Miniflare options from bindings — extracted for testing.
|
|
51
|
+
* @internal
|
|
52
|
+
*/
|
|
53
|
+
export declare function buildMiniflareBindingOptions(bindings: BindingDeclaration[]): {
|
|
54
|
+
hasD1: boolean;
|
|
55
|
+
hasKV: boolean;
|
|
56
|
+
hasR2: boolean;
|
|
57
|
+
d1BindingName: string;
|
|
58
|
+
kvBindingName: string;
|
|
59
|
+
r2BindingName: string;
|
|
60
|
+
};
|
|
61
|
+
//# sourceMappingURL=worker-runner.d.ts.map
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Worker execution for `creek dev` — bundles user code and runs it via Miniflare.
|
|
2
|
+
//
|
|
3
|
+
// Uses esbuild watch mode for hot reload and Miniflare for D1/KV/R2 simulation.
|
|
4
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { context } from "esbuild";
|
|
7
|
+
import { Miniflare } from "miniflare";
|
|
8
|
+
import { generateWorkerWrapper } from "../utils/worker-bundle.js";
|
|
9
|
+
const NODE_BUILTINS = [
|
|
10
|
+
"node:async_hooks",
|
|
11
|
+
"node:stream",
|
|
12
|
+
"node:stream/web",
|
|
13
|
+
"node:buffer",
|
|
14
|
+
"node:util",
|
|
15
|
+
"node:events",
|
|
16
|
+
"node:crypto",
|
|
17
|
+
"node:path",
|
|
18
|
+
"node:url",
|
|
19
|
+
"node:string_decoder",
|
|
20
|
+
"node:diagnostics_channel",
|
|
21
|
+
"node:process",
|
|
22
|
+
"node:fs",
|
|
23
|
+
"node:os",
|
|
24
|
+
"node:child_process",
|
|
25
|
+
"node:http",
|
|
26
|
+
"node:https",
|
|
27
|
+
"node:net",
|
|
28
|
+
"node:tls",
|
|
29
|
+
"node:zlib",
|
|
30
|
+
"node:perf_hooks",
|
|
31
|
+
"node:worker_threads",
|
|
32
|
+
];
|
|
33
|
+
export class WorkerRunner {
|
|
34
|
+
mf = null;
|
|
35
|
+
esbuildCtx = null;
|
|
36
|
+
options;
|
|
37
|
+
mfOptions = {};
|
|
38
|
+
port = 0;
|
|
39
|
+
constructor(options) {
|
|
40
|
+
this.options = options;
|
|
41
|
+
}
|
|
42
|
+
async start() {
|
|
43
|
+
const { cwd, entryPoint, persistDir } = this.options;
|
|
44
|
+
// 1. Generate worker wrapper
|
|
45
|
+
const wrapperDir = join(cwd, ".creek", "dev");
|
|
46
|
+
mkdirSync(wrapperDir, { recursive: true });
|
|
47
|
+
const entryAbsolute = resolve(cwd, entryPoint);
|
|
48
|
+
const wrapper = generateWorkerWrapper(entryAbsolute, wrapperDir, {
|
|
49
|
+
hasClientAssets: this.options.hasClientAssets,
|
|
50
|
+
});
|
|
51
|
+
const wrapperPath = join(wrapperDir, "__worker_entry.js");
|
|
52
|
+
writeFileSync(wrapperPath, wrapper);
|
|
53
|
+
// 2. Initial bundle with esbuild
|
|
54
|
+
const bundledScript = await this.bundle(wrapperPath);
|
|
55
|
+
// 3. Start Miniflare
|
|
56
|
+
this.mfOptions = this.buildMiniflareOptions(bundledScript, persistDir);
|
|
57
|
+
this.mf = new Miniflare(this.mfOptions);
|
|
58
|
+
// Wait for Miniflare to be ready and get the port
|
|
59
|
+
const readyUrl = await this.mf.ready;
|
|
60
|
+
// mf.ready returns a URL object
|
|
61
|
+
this.port = readyUrl.port ? parseInt(String(readyUrl.port), 10) : 8787;
|
|
62
|
+
// 4. Start esbuild watch mode for hot reload
|
|
63
|
+
await this.startWatching(wrapperPath);
|
|
64
|
+
return { port: this.port };
|
|
65
|
+
}
|
|
66
|
+
async stop() {
|
|
67
|
+
if (this.esbuildCtx) {
|
|
68
|
+
await this.esbuildCtx.dispose();
|
|
69
|
+
this.esbuildCtx = null;
|
|
70
|
+
}
|
|
71
|
+
if (this.mf) {
|
|
72
|
+
await this.mf.dispose();
|
|
73
|
+
this.mf = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Get the URL the worker is running on. */
|
|
77
|
+
getUrl() {
|
|
78
|
+
return `http://127.0.0.1:${this.port}`;
|
|
79
|
+
}
|
|
80
|
+
/** Get the port the worker is running on. */
|
|
81
|
+
getPort() {
|
|
82
|
+
return this.port;
|
|
83
|
+
}
|
|
84
|
+
/** Dispatch a fetch request to the worker (via Miniflare). */
|
|
85
|
+
async dispatchFetch(input, init) {
|
|
86
|
+
if (!this.mf)
|
|
87
|
+
throw new Error("WorkerRunner not started");
|
|
88
|
+
return this.mf.dispatchFetch(input, init);
|
|
89
|
+
}
|
|
90
|
+
// ─── Internal ───────────────────────────────────────────────────────────────
|
|
91
|
+
buildMiniflareOptions(script, persistDir) {
|
|
92
|
+
const { bindings, projectSlug, realtimeUrl, vars } = this.options;
|
|
93
|
+
const hasD1 = bindings.some((b) => b.type === "d1");
|
|
94
|
+
const hasKV = bindings.some((b) => b.type === "kv");
|
|
95
|
+
const hasR2 = bindings.some((b) => b.type === "r2");
|
|
96
|
+
const d1BindingName = bindings.find((b) => b.type === "d1")?.name ?? "DB";
|
|
97
|
+
const kvBindingName = bindings.find((b) => b.type === "kv")?.name ?? "KV";
|
|
98
|
+
const r2BindingName = bindings.find((b) => b.type === "r2")?.name ?? "STORAGE";
|
|
99
|
+
const opts = {
|
|
100
|
+
modules: true,
|
|
101
|
+
script,
|
|
102
|
+
compatibilityDate: "2024-12-01",
|
|
103
|
+
compatibilityFlags: ["nodejs_compat"],
|
|
104
|
+
// Environment variables
|
|
105
|
+
bindings: {
|
|
106
|
+
CREEK_PROJECT_SLUG: projectSlug,
|
|
107
|
+
CREEK_REALTIME_URL: realtimeUrl,
|
|
108
|
+
// CREEK_REALTIME_SECRET intentionally omitted (dev mode = no auth)
|
|
109
|
+
...vars,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
// D1 — SQLite-backed
|
|
113
|
+
if (hasD1) {
|
|
114
|
+
opts.d1Databases = { [d1BindingName]: "creek-dev-db" };
|
|
115
|
+
opts.d1Persist = persistDir ? join(persistDir, "d1") : false;
|
|
116
|
+
}
|
|
117
|
+
// KV
|
|
118
|
+
if (hasKV) {
|
|
119
|
+
opts.kvNamespaces = { [kvBindingName]: "creek-dev-kv" };
|
|
120
|
+
opts.kvPersist = persistDir ? join(persistDir, "kv") : false;
|
|
121
|
+
}
|
|
122
|
+
// R2
|
|
123
|
+
if (hasR2) {
|
|
124
|
+
opts.r2Buckets = { [r2BindingName]: "creek-dev-r2" };
|
|
125
|
+
opts.r2Persist = persistDir ? join(persistDir, "r2") : false;
|
|
126
|
+
}
|
|
127
|
+
return opts;
|
|
128
|
+
}
|
|
129
|
+
get esbuildOptions() {
|
|
130
|
+
return {
|
|
131
|
+
absWorkingDir: this.options.cwd,
|
|
132
|
+
bundle: true,
|
|
133
|
+
format: "esm",
|
|
134
|
+
platform: "neutral",
|
|
135
|
+
target: "es2022",
|
|
136
|
+
write: false,
|
|
137
|
+
minify: false,
|
|
138
|
+
external: NODE_BUILTINS,
|
|
139
|
+
conditions: ["workerd", "worker", "import"],
|
|
140
|
+
mainFields: ["module", "main"],
|
|
141
|
+
logLevel: "warning",
|
|
142
|
+
...(this.options.nodePaths?.length
|
|
143
|
+
? { nodePaths: this.options.nodePaths }
|
|
144
|
+
: {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
async bundle(entryPoint) {
|
|
148
|
+
const result = await import("esbuild").then((esbuild) => esbuild.build({
|
|
149
|
+
entryPoints: [entryPoint],
|
|
150
|
+
...this.esbuildOptions,
|
|
151
|
+
}));
|
|
152
|
+
if (result.errors.length > 0) {
|
|
153
|
+
throw new Error(`esbuild: ${result.errors.map((e) => e.text).join(", ")}`);
|
|
154
|
+
}
|
|
155
|
+
return result.outputFiles[0].text;
|
|
156
|
+
}
|
|
157
|
+
async startWatching(entryPoint) {
|
|
158
|
+
this.esbuildCtx = await context({
|
|
159
|
+
entryPoints: [entryPoint],
|
|
160
|
+
...this.esbuildOptions,
|
|
161
|
+
plugins: [
|
|
162
|
+
{
|
|
163
|
+
name: "creek-hot-reload",
|
|
164
|
+
setup: (build) => {
|
|
165
|
+
build.onEnd(async (result) => {
|
|
166
|
+
if (result.errors.length > 0 || !result.outputFiles?.length) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const start = Date.now();
|
|
170
|
+
const newScript = result.outputFiles[0].text;
|
|
171
|
+
try {
|
|
172
|
+
await this.mf?.setOptions({
|
|
173
|
+
...this.mfOptions,
|
|
174
|
+
script: newScript,
|
|
175
|
+
});
|
|
176
|
+
this.options.onRebuild?.(Date.now() - start);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Reload failed — keep running with old version
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
await this.esbuildCtx.watch();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Build Miniflare options from bindings — extracted for testing.
|
|
191
|
+
* @internal
|
|
192
|
+
*/
|
|
193
|
+
export function buildMiniflareBindingOptions(bindings) {
|
|
194
|
+
return {
|
|
195
|
+
hasD1: bindings.some((b) => b.type === "d1"),
|
|
196
|
+
hasKV: bindings.some((b) => b.type === "kv"),
|
|
197
|
+
hasR2: bindings.some((b) => b.type === "r2"),
|
|
198
|
+
d1BindingName: bindings.find((b) => b.type === "d1")?.name ?? "DB",
|
|
199
|
+
kvBindingName: bindings.find((b) => b.type === "kv")?.name ?? "KV",
|
|
200
|
+
r2BindingName: bindings.find((b) => b.type === "r2")?.name ?? "STORAGE",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=worker-runner.js.map
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { defineCommand, runMain } from "citty";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
6
7
|
import { loginCommand } from "./commands/login.js";
|
|
7
8
|
import { whoamiCommand } from "./commands/whoami.js";
|
|
8
9
|
import { initCommand } from "./commands/init.js";
|
|
@@ -13,15 +14,28 @@ import { domainsCommand } from "./commands/domains.js";
|
|
|
13
14
|
import { projectsCommand } from "./commands/projects.js";
|
|
14
15
|
import { deploymentsCommand } from "./commands/deployments.js";
|
|
15
16
|
import { statusCommand } from "./commands/status.js";
|
|
17
|
+
import { devCommand } from "./commands/dev.js";
|
|
16
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
-
const
|
|
19
|
+
const cliPkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
20
|
+
// Read version from the "creek" facade package (what users install),
|
|
21
|
+
// falling back to CLI's own version if not available.
|
|
22
|
+
let version = cliPkg.version;
|
|
23
|
+
try {
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const facadePkg = require("creek/package.json");
|
|
26
|
+
version = facadePkg.version;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Running outside facade (e.g. workspace dev) — use CLI version
|
|
30
|
+
}
|
|
18
31
|
const main = defineCommand({
|
|
19
32
|
meta: {
|
|
20
33
|
name: "creek",
|
|
21
|
-
version
|
|
34
|
+
version,
|
|
22
35
|
description: "Deploy full-stack apps to the edge",
|
|
23
36
|
},
|
|
24
37
|
subCommands: {
|
|
38
|
+
dev: devCommand,
|
|
25
39
|
deploy: deployCommand,
|
|
26
40
|
status: statusCommand,
|
|
27
41
|
projects: projectsCommand,
|
|
@@ -23,7 +23,7 @@ export function generateWorkerWrapper(entryPoint, wrapperDir, options) {
|
|
|
23
23
|
// - Fall back to the worker handler for API routes
|
|
24
24
|
// - SPA fallback: serve index.html for extensionless paths
|
|
25
25
|
if (hasAssets) {
|
|
26
|
-
return `import {
|
|
26
|
+
return `import { _runRequest, generateWsToken } from "creek";
|
|
27
27
|
import userModule from "${importPath}";
|
|
28
28
|
|
|
29
29
|
const handler = userModule.default ?? userModule;
|
|
@@ -39,81 +39,80 @@ function hasExtension(pathname) {
|
|
|
39
39
|
|
|
40
40
|
export default {
|
|
41
41
|
async fetch(request, env, ctx) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const url = new URL(request.url);
|
|
42
|
+
return _runRequest(env, ctx, async () => {
|
|
43
|
+
const url = new URL(request.url);
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// API routes → always go to the worker handler
|
|
57
|
-
if (isApiPath(url.pathname)) {
|
|
58
|
-
if (typeof handler.fetch === "function") return handler.fetch(request, env, ctx);
|
|
59
|
-
if (typeof handler === "function") return handler(request, env, ctx);
|
|
60
|
-
}
|
|
45
|
+
// /__creek/config — auto-discovery for CreekRoom WebSocket
|
|
46
|
+
if (url.pathname === "/__creek/config" && request.method === "GET") {
|
|
47
|
+
const wsToken = await generateWsToken();
|
|
48
|
+
return new Response(JSON.stringify({
|
|
49
|
+
realtimeUrl: env.CREEK_REALTIME_URL || null,
|
|
50
|
+
projectSlug: env.CREEK_PROJECT_SLUG || null,
|
|
51
|
+
wsToken,
|
|
52
|
+
}), { headers: { "Content-Type": "application/json" } });
|
|
53
|
+
}
|
|
61
54
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
} catch {}
|
|
55
|
+
// API routes → always go to the worker handler
|
|
56
|
+
if (isApiPath(url.pathname)) {
|
|
57
|
+
if (typeof handler.fetch === "function") return handler.fetch(request, env, ctx);
|
|
58
|
+
if (typeof handler === "function") return handler(request, env, ctx);
|
|
59
|
+
}
|
|
68
60
|
|
|
69
|
-
//
|
|
70
|
-
if (
|
|
61
|
+
// Static assets → try WfP Static Assets API
|
|
62
|
+
if (env.ASSETS) {
|
|
71
63
|
try {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
if (indexResponse.status !== 404) {
|
|
75
|
-
return new Response(indexResponse.body, {
|
|
76
|
-
status: 200,
|
|
77
|
-
headers: indexResponse.headers,
|
|
78
|
-
});
|
|
79
|
-
}
|
|
64
|
+
const assetResponse = await env.ASSETS.fetch(request);
|
|
65
|
+
if (assetResponse.status !== 404) return assetResponse;
|
|
80
66
|
} catch {}
|
|
67
|
+
|
|
68
|
+
// SPA fallback: extensionless paths → index.html
|
|
69
|
+
if (!hasExtension(url.pathname)) {
|
|
70
|
+
try {
|
|
71
|
+
const indexReq = new Request(new URL("/index.html", request.url), request);
|
|
72
|
+
const indexResponse = await env.ASSETS.fetch(indexReq);
|
|
73
|
+
if (indexResponse.status !== 404) {
|
|
74
|
+
return new Response(indexResponse.body, {
|
|
75
|
+
status: 200,
|
|
76
|
+
headers: indexResponse.headers,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
81
81
|
}
|
|
82
|
-
}
|
|
83
82
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
// Fallback: pass to worker handler
|
|
84
|
+
if (typeof handler.fetch === "function") return handler.fetch(request, env, ctx);
|
|
85
|
+
if (typeof handler === "function") return handler(request, env, ctx);
|
|
86
|
+
return new Response("Not Found", { status: 404 });
|
|
87
|
+
});
|
|
88
88
|
},
|
|
89
89
|
};
|
|
90
90
|
`;
|
|
91
91
|
}
|
|
92
92
|
// Pure Worker (no client assets)
|
|
93
|
-
return `import {
|
|
93
|
+
return `import { _runRequest, generateWsToken } from "creek";
|
|
94
94
|
import userModule from "${importPath}";
|
|
95
95
|
|
|
96
96
|
const handler = userModule.default ?? userModule;
|
|
97
97
|
|
|
98
98
|
export default {
|
|
99
99
|
async fetch(request, env, ctx) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}), { headers: { "Content-Type": "application/json" } });
|
|
112
|
-
}
|
|
100
|
+
return _runRequest(env, ctx, async () => {
|
|
101
|
+
// /__creek/config — auto-discovery for CreekRoom WebSocket
|
|
102
|
+
const url = new URL(request.url);
|
|
103
|
+
if (url.pathname === "/__creek/config" && request.method === "GET") {
|
|
104
|
+
const wsToken = await generateWsToken();
|
|
105
|
+
return new Response(JSON.stringify({
|
|
106
|
+
realtimeUrl: env.CREEK_REALTIME_URL || null,
|
|
107
|
+
projectSlug: env.CREEK_PROJECT_SLUG || null,
|
|
108
|
+
wsToken,
|
|
109
|
+
}), { headers: { "Content-Type": "application/json" } });
|
|
110
|
+
}
|
|
113
111
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
if (typeof handler.fetch === "function") return handler.fetch(request, env, ctx);
|
|
113
|
+
if (typeof handler === "function") return handler(request, env, ctx);
|
|
114
|
+
throw new Error("[creek] Worker must export default a fetch handler, Hono app, or { fetch() } object.");
|
|
115
|
+
});
|
|
117
116
|
},
|
|
118
117
|
};
|
|
119
118
|
`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solcreek/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "CLI for the Creek deployment platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
"!dist/**/*.map",
|
|
11
11
|
"LICENSE"
|
|
12
12
|
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"dev": "tsc --watch",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"clean": "rm -rf dist"
|
|
18
|
+
},
|
|
13
19
|
"keywords": [
|
|
14
20
|
"creek",
|
|
15
21
|
"cli",
|
|
@@ -26,11 +32,13 @@
|
|
|
26
32
|
"directory": "packages/cli"
|
|
27
33
|
},
|
|
28
34
|
"dependencies": {
|
|
35
|
+
"@solcreek/sdk": "workspace:*",
|
|
29
36
|
"citty": "^0.1.6",
|
|
30
37
|
"consola": "^3.4.2",
|
|
31
38
|
"esbuild": "^0.25.0",
|
|
39
|
+
"miniflare": "^4.20260317.3",
|
|
32
40
|
"smol-toml": "^1.3.1",
|
|
33
|
-
"
|
|
41
|
+
"ws": "^8.20.0"
|
|
34
42
|
},
|
|
35
43
|
"optionalDependencies": {
|
|
36
44
|
"@solcreek/adapter-creek": "*"
|
|
@@ -40,16 +48,12 @@
|
|
|
40
48
|
"@testing-library/react": "^16.3.2",
|
|
41
49
|
"@types/node": "^22.19.15",
|
|
42
50
|
"@types/react-dom": "^19",
|
|
51
|
+
"@types/ws": "^8.18.1",
|
|
43
52
|
"hono": "^4.7.4",
|
|
44
53
|
"jsdom": "^29.0.1",
|
|
45
54
|
"react": "^19.2.4",
|
|
46
55
|
"react-dom": "^19.2.4",
|
|
47
|
-
"typescript": "^5.8.2"
|
|
48
|
-
|
|
49
|
-
"scripts": {
|
|
50
|
-
"build": "tsc",
|
|
51
|
-
"dev": "tsc --watch",
|
|
52
|
-
"typecheck": "tsc --noEmit",
|
|
53
|
-
"clean": "rm -rf dist"
|
|
56
|
+
"typescript": "^5.8.2",
|
|
57
|
+
"vite": "^6.3.5"
|
|
54
58
|
}
|
|
55
|
-
}
|
|
59
|
+
}
|