vmlive 1.0.2 → 1.0.4

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/README.md CHANGED
@@ -29,9 +29,16 @@ Interactively scaffolds additional serverless endpoints natively into your `src/
29
29
  ### `npx vmlive dev`
30
30
  Starts the local emulation environment.
31
31
  - Iteratively hosts your entire multi-function footprint locally via predictable slugs: `http://<workspace_id>-<project_id>-<function_name>.localhost:8787`.
32
+ - **Local Dashboard:** Automatically spawns a web dashboard at `http://dashboard.localhost:8787` for visualizing offline databases, global KV, and Object Storage.
32
33
  - Provides local proxy bindings for your SQL Database (`env.DB`), Global KV (`env.KV`), and Object Storage (`env.BUCKET`) using the `.vmlive/` directory for persistent storage.
33
34
  - Supports hot-reloading for `.js` and `.ts` files.
34
35
 
36
+ ### `npx vmlive test`
37
+ Executes your Vitest suites against a strictly ephemeral **in-memory** engine.
38
+ - Spins up `VirtualMachine` in memory-only mode without touching `.vmlive/` disk databases.
39
+ - Runs `npx vitest run` and gracefully destroys the engine upon exit.
40
+ - Allows test suites to deeply mutate state predictably without sandbox bleeding.
41
+
35
42
  ### `npx vmlive deploy`
36
43
  Uploads the local code to the vm.live edge platform.
37
44
  - Bundles the code using `esbuild`.
@@ -42,6 +49,5 @@ Uploads the local code to the vm.live edge platform.
42
49
  If you are using an AI assistant (such as Cursor, Copilot, or Aider) to write code, provide it with the platform context to ensure it adheres to the V8 Isolate execution model and uses the correct infrastructure bindings.
43
50
 
44
51
  **Instructions:**
45
- 1. Download the `vmlive-ai-rules.md` file (or relevant rules document) provided by the platform.
46
- 2. Place it in the root directory of your project.
47
- 3. If using Cursor, rename the file to `.cursorrules` to force the AI to apply the constraints automatically.
52
+ 1. Download the rules seamlessly via the API directly into your local root footprint: `curl -O https://api.vm.live/vmlive-ai-rules.md`
53
+ 2. Configure your AI Assistant (e.g. Cursor, Copilot) to ingest this manifest so it dynamically enforces the strict PaaS architectural primitives across your local logic scope!
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vmlive",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Local development VM for custom Serverless PaaS",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -28,31 +28,51 @@ const buildResourcesConfig = () => {
28
28
  };
29
29
  };
30
30
 
31
- const buildProxyDispatcher = (functions, workspaceId, projectId) => ({
31
+ const buildProxyDispatcher = (functions, workspaceId, projectId, includeDashboard = true) => ({
32
32
  name: "vm-gateway-proxy",
33
33
  modules: true,
34
34
  script: `
35
35
  export default {
36
36
  async fetch(request, env) {
37
37
  const url = new URL(request.url);
38
+
39
+ if (url.pathname === '/favicon.ico') {
40
+ return new Response(null, { status: 204 });
41
+ }
42
+
38
43
  const hostHeader = request.headers.get("Host") || url.hostname;
39
44
  let targetName = hostHeader.split('.')[0];
40
45
 
41
- if (targetName && env[targetName]) {
42
- return env[targetName].fetch(request);
46
+ let res;
47
+
48
+ if (targetName === 'dashboard' && env.DASHBOARD) {
49
+ res = await env.DASHBOARD.fetch(request);
50
+ } else if (targetName && env[targetName]) {
51
+ res = await env[targetName].fetch(request);
52
+ } else {
53
+ res = new Response(
54
+ \`Function Not Found: \${targetName}.\\n\\nExpected format: http://${workspaceId}-${projectId}-<function_name>.localhost\`,
55
+ { status: 404 }
56
+ );
43
57
  }
58
+
59
+ const method = request.method;
60
+ const path = url.pathname;
61
+ const status = res.status;
62
+ const color = status >= 500 ? "\\x1b[31m" : status >= 400 ? "\\x1b[33m" : "\\x1b[32m";
44
63
 
45
- return new Response(
46
- \`Function Not Found: \${targetName}.\\n\\nExpected format: http://${workspaceId}-${projectId}-<function_name>.localhost\`,
47
- { status: 404 }
48
- );
64
+ console.log(\`\\x1b[90m[\${targetName}]\\x1b[0m \${method} \${path} \${color}\${status}\\x1b[0m\`);
65
+ return res;
49
66
  }
50
67
  }
51
68
  `,
52
- serviceBindings: functions.reduce((acc, fn) => {
53
- acc[`${workspaceId}-${projectId}-${fn.name}`] = fn.name;
54
- return acc;
55
- }, {})
69
+ serviceBindings: {
70
+ ...functions.reduce((acc, fn) => {
71
+ acc[`${workspaceId}-${projectId}-${fn.name}`] = fn.name;
72
+ return acc;
73
+ }, {}),
74
+ ...(includeDashboard ? { DASHBOARD: "vm-dashboard-worker" } : {})
75
+ }
56
76
  });
57
77
 
58
78
  const runInit = async () => {
@@ -138,7 +158,7 @@ const runInit = async () => {
138
158
 
139
159
  if (stripeRes.ok) {
140
160
  const stripeData = await stripeRes.json();
141
- console.log(`\n\x1b[34m${stripeData.url}\x1b[0m\n`);
161
+ console.log(`\n\x1b]8;;${stripeData.url}\x1b\\\x1b[34m${stripeData.url}\x1b[0m\x1b]8;;\x1b\\\n`);
142
162
  const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
143
163
  exec(`${openCmd} "${stripeData.url}"`);
144
164
  await input({ message: 'Press Enter once verified to continue:' });
@@ -401,10 +421,21 @@ const runDev = async () => {
401
421
  const shadowSource = fs.readFileSync(path.join(__dirname, 'shadow-dos.js'), 'utf-8');
402
422
  fs.writeFileSync(path.join(WORK_DIR, 'shadow-dos.mjs'), shadowSource);
403
423
 
424
+ const dashSource = fs.readFileSync(path.join(__dirname, 'dashboard-worker.js'), 'utf-8');
425
+ fs.writeFileSync(path.join(WORK_DIR, 'dashboard-worker.mjs'), dashSource);
426
+
404
427
  const mfPort = process.env.PORT ? parseInt(process.env.PORT) : (config.port || 8787);
405
428
 
406
429
  const miniflareWorkers = [
407
430
  buildProxyDispatcher(config.functions, workspaceId, projectId),
431
+ {
432
+ name: "vm-dashboard-worker",
433
+ modules: true,
434
+ scriptPath: path.join(WORK_DIR, 'dashboard-worker.mjs'),
435
+ d1Databases: resourcesConfig.d1Databases,
436
+ kvNamespaces: resourcesConfig.kvNamespaces,
437
+ r2Buckets: resourcesConfig.r2Buckets
438
+ },
408
439
  {
409
440
  name: "vm-shadow-worker",
410
441
  modules: true,
@@ -436,9 +467,14 @@ const runDev = async () => {
436
467
  r2Persist: path.join(DATA_DIR, 'bucket')
437
468
  });
438
469
  await mf.ready;
439
- console.log('\\nLocal endpoints available:');
470
+ console.log('\nLocal endpoints available:');
471
+
472
+ const dashboardUrl = `http://dashboard.localhost:${mfPort}`;
473
+ console.log(` \x1b]8;;${dashboardUrl}\x1b\\${dashboardUrl}\x1b]8;;\x1b\\ \x1b[32m[Dashboard]\x1b[0m`);
474
+
440
475
  config.functions.forEach(fn => {
441
- console.log(` http://${workspaceId}-${projectId}-${fn.name}.localhost:${mfPort}`);
476
+ const endpointUrl = `http://${workspaceId}-${projectId}-${fn.name}.localhost:${mfPort}`;
477
+ console.log(` \x1b]8;;${endpointUrl}\x1b\\${endpointUrl}\x1b]8;;\x1b\\`);
442
478
  });
443
479
 
444
480
  setInterval(() => {
@@ -489,6 +525,100 @@ const runDev = async () => {
489
525
  });
490
526
  };
491
527
 
528
+ const runTest = async () => {
529
+ if (!fs.existsSync(CONFIG_PATH)) {
530
+ throw new Error("Missing vm.json in project root. Run 'vm init' to scaffold a new project workspace.");
531
+ }
532
+
533
+ const testArgs = process.argv.slice(3);
534
+ console.log('\x1b[36mInitializing Ephemeral Target Engine for Integration Tests...\x1b[0m');
535
+
536
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
537
+ if (!fs.existsSync(WORK_DIR)) fs.mkdirSync(WORK_DIR, { recursive: true });
538
+
539
+ const sharedBindings = fs.existsSync(path.resolve('.env'))
540
+ ? dotenv.parse(fs.readFileSync(path.resolve('.env')))
541
+ : {};
542
+
543
+ const resourcesConfig = buildResourcesConfig();
544
+
545
+ const entryPoints = config.functions.reduce((acc, fn) => {
546
+ acc[`${fn.name}-out`] = path.resolve(fn.entry);
547
+ return acc;
548
+ }, {});
549
+
550
+ const workspaceId = config.workspaceId || "ws_local";
551
+ const projectId = config.projectId || "prj_local";
552
+
553
+ config.functions.forEach(fn => {
554
+ const relativeTarget = `./${fn.name}-out.mjs`;
555
+ fs.writeFileSync(path.join(WORK_DIR, `${fn.name}-shim.mjs`), generateShim(relativeTarget, workspaceId, projectId, fn.name));
556
+ });
557
+
558
+ const builder = await esbuild.context({
559
+ entryPoints,
560
+ bundle: true,
561
+ format: 'esm',
562
+ outdir: WORK_DIR,
563
+ outExtension: { '.js': '.mjs' },
564
+ external: ['cloudflare:*'],
565
+ logLevel: 'silent'
566
+ });
567
+
568
+ await builder.rebuild();
569
+
570
+ const shadowSource = fs.readFileSync(path.join(__dirname, 'shadow-dos.js'), 'utf-8');
571
+ fs.writeFileSync(path.join(WORK_DIR, 'shadow-dos.mjs'), shadowSource);
572
+
573
+ const mfPort = process.env.PORT ? parseInt(process.env.PORT) : 8787;
574
+
575
+ // Exact parity workers but omitting the Dashboard intentionally
576
+ const miniflareWorkers = [
577
+ buildProxyDispatcher(config.functions, workspaceId, projectId, false),
578
+ {
579
+ name: "vm-shadow-worker",
580
+ modules: true,
581
+ scriptPath: path.join(WORK_DIR, 'shadow-dos.mjs'),
582
+ bindings: { PORT: mfPort },
583
+ durableObjects: { LocalTaskManagerDO: "LocalTaskManagerDO", LocalChannelRoomDO: "LocalChannelRoomDO" }
584
+ },
585
+ ...config.functions.map(fn => ({
586
+ name: fn.name,
587
+ modules: true,
588
+ scriptPath: path.join(WORK_DIR, `${fn.name}-shim.mjs`),
589
+ bindings: sharedBindings,
590
+ ...resourcesConfig,
591
+ durableObjects: {
592
+ TASK_DO: { className: "LocalTaskManagerDO", scriptName: "vm-shadow-worker" },
593
+ CHANNEL_DO: { className: "LocalChannelRoomDO", scriptName: "vm-shadow-worker" }
594
+ }
595
+ }))
596
+ ];
597
+
598
+ // IN-MEMORY EMULATOR (NO PERSIST PATHS)
599
+ const mf = new Emulator({
600
+ workers: miniflareWorkers,
601
+ port: mfPort
602
+ });
603
+
604
+ await mf.ready;
605
+ console.log('\x1b[32m✔ Ephemeral environment completely provisioned and bound.\x1b[0m\\n');
606
+
607
+ const { spawn } = await import('child_process');
608
+
609
+ const testProcess = spawn('npx', ['vitest', 'run', ...testArgs], {
610
+ stdio: 'inherit',
611
+ env: { ...process.env, VMLIVE_TESTING: "true" }
612
+ });
613
+
614
+ testProcess.on('exit', async (code) => {
615
+ console.log('\\n\x1b[36mTerminating ephemeral engine execution context...\x1b[0m');
616
+ await builder.dispose();
617
+ await mf.dispose();
618
+ process.exit(code || 0);
619
+ });
620
+ };
621
+
492
622
  const runDeploy = async () => {
493
623
  console.log('\x1b[36mDeploying...\x1b[0m');
494
624
 
@@ -633,7 +763,7 @@ const runLogin = async () => {
633
763
  const PORT = server.address().port;
634
764
  const EDITOR_URL = process.env.EDITOR_URL || 'http://localhost:5174';
635
765
  const authUrl = `${EDITOR_URL}/?challenge=${codeChallenge}&port=${PORT}`;
636
- console.log(`\x1b[34mOpening browser to: ${authUrl}\x1b[0m`);
766
+ console.log(`\x1b[34mOpening browser to: \x1b]8;;${authUrl}\x1b\\${authUrl}\x1b]8;;\x1b\\\x1b[0m`);
637
767
  const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
638
768
  exec(`${openCmd} "${authUrl}"`);
639
769
  });
@@ -645,15 +775,17 @@ const main = async () => {
645
775
  runInit();
646
776
  } else if (command === 'add') {
647
777
  await runAdd();
648
- } else if (command === 'dev' || !command) {
778
+ } else if (command === 'dev') {
649
779
  await runDev();
780
+ } else if (command === 'test') {
781
+ await runTest();
650
782
  } else if (command === 'login') {
651
783
  await runLogin();
652
784
  } else if (command === 'deploy') {
653
785
  await runDeploy();
654
786
  } else {
655
- console.error(`\x1b[31m❌ Unknown command: ${command}\x1b[0m`);
656
- console.log('Usage: vm init | vm add | vm dev | vm login | vm deploy');
787
+ console.error(`\x1b[31m❌ Unknown command: ${command || 'missing'}\x1b[0m`);
788
+ console.log('Usage: vm init | vm add | vm dev | vm test | vm login | vm deploy');
657
789
  process.exit(1);
658
790
  }
659
791
  };
@@ -0,0 +1,149 @@
1
+ const UI_HTML = `
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <title>vm.live — Local Dashboard</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ body { font-family: 'Inter', sans-serif; background-color: #09090b; color: #f4f4f5; }
11
+ .tab-active { border-bottom: 2px solid #10b981; color: #10b981; }
12
+ .tab-inactive { color: #a1a1aa; }
13
+ .tab-inactive:hover { color: #f4f4f5; }
14
+ pre { background: #18181b; padding: 1rem; border-radius: 0.5rem; border: 1px solid #27272a; overflow-x: auto; font-size: 0.875rem; color: #a1a1aa; }
15
+ </style>
16
+ </head>
17
+ <body class="h-screen flex flex-col">
18
+ <header class="border-b border-zinc-800 bg-zinc-950 p-4 shrink-0 flex items-center justify-between">
19
+ <div class="flex items-center gap-3">
20
+ <div class="w-8 h-8 rounded-full bg-emerald-500/10 text-emerald-500 flex items-center justify-center font-bold">V</div>
21
+ <h1 class="font-semibold tracking-tight">Local Sandbox</h1>
22
+ </div>
23
+ <div class="flex gap-6 text-sm font-medium">
24
+ <button onclick="switchTab('db')" id="tab-db" class="tab-active pb-4 -mb-4">Database (D1)</button>
25
+ <button onclick="switchTab('kv')" id="tab-kv" class="tab-inactive pb-4 -mb-4">Global KV</button>
26
+ <button onclick="switchTab('r2')" id="tab-r2" class="tab-inactive pb-4 -mb-4">Storage (R2)</button>
27
+ </div>
28
+ </header>
29
+
30
+ <main class="flex-1 overflow-hidden relative">
31
+ <!-- D1 View -->
32
+ <div id="view-db" class="absolute inset-0 flex flex-col p-6 gap-4">
33
+ <h2 class="text-lg font-semibold">Execute SQL Query</h2>
34
+ <div class="flex gap-2">
35
+ <input type="text" id="sql-input" placeholder="SELECT * FROM users;" class="flex-1 bg-zinc-900 border border-zinc-800 rounded-md px-4 py-2 font-mono text-sm focus:outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500">
36
+ <button onclick="runQuery()" class="bg-emerald-600 hover:bg-emerald-500 text-white px-6 py-2 rounded-md font-medium transition-colors">Run</button>
37
+ </div>
38
+ <div class="flex-1 overflow-auto rounded-md border border-zinc-800 bg-zinc-900/50">
39
+ <pre id="db-results" class="h-full m-0 rounded-none border-0 text-sm">Awaiting query...</pre>
40
+ </div>
41
+ </div>
42
+
43
+ <!-- KV View -->
44
+ <div id="view-kv" class="absolute inset-0 flex flex-col p-6 gap-4 hidden">
45
+ <div class="flex items-center justify-between">
46
+ <h2 class="text-lg font-semibold">Key-Value Pairs</h2>
47
+ <button onclick="loadKV()" class="text-xs bg-zinc-800 hover:bg-zinc-700 px-3 py-1.5 rounded-md transition-colors">Refresh</button>
48
+ </div>
49
+ <div class="flex-1 overflow-auto rounded-md border border-zinc-800 bg-zinc-900/50">
50
+ <pre id="kv-results" class="h-full m-0 rounded-none border-0 text-sm">Loading keys...</pre>
51
+ </div>
52
+ </div>
53
+
54
+ <!-- R2 View -->
55
+ <div id="view-r2" class="absolute inset-0 flex flex-col p-6 gap-4 hidden">
56
+ <div class="flex items-center justify-between">
57
+ <h2 class="text-lg font-semibold">Object Storage</h2>
58
+ <button onclick="loadR2()" class="text-xs bg-zinc-800 hover:bg-zinc-700 px-3 py-1.5 rounded-md transition-colors">Refresh</button>
59
+ </div>
60
+ <div class="flex-1 overflow-auto rounded-md border border-zinc-800 bg-zinc-900/50">
61
+ <pre id="r2-results" class="h-full m-0 rounded-none border-0 text-sm">Loading objects...</pre>
62
+ </div>
63
+ </div>
64
+ </main>
65
+
66
+ <script>
67
+ const views = ['db', 'kv', 'r2'];
68
+ function switchTab(target) {
69
+ views.forEach(v => {
70
+ document.getElementById('view-' + v).classList.add('hidden');
71
+ document.getElementById('tab-' + v).className = 'tab-inactive pb-4 -mb-4';
72
+ });
73
+ document.getElementById('view-' + target).classList.remove('hidden');
74
+ document.getElementById('tab-' + target).className = 'tab-active pb-4 -mb-4';
75
+
76
+ if (target === 'kv') loadKV();
77
+ if (target === 'r2') loadR2();
78
+ }
79
+
80
+ async function runQuery() {
81
+ const q = document.getElementById('sql-input').value;
82
+ if (!q) return;
83
+ document.getElementById('db-results').innerText = 'Executing...';
84
+ try {
85
+ const res = await fetch('/api/db', { method: 'POST', body: JSON.stringify({ query: q }) });
86
+ const json = await res.json();
87
+ document.getElementById('db-results').innerText = JSON.stringify(json, null, 2);
88
+ } catch (e) {
89
+ document.getElementById('db-results').innerText = 'Network Error: ' + e.message;
90
+ }
91
+ }
92
+
93
+ async function loadKV() {
94
+ document.getElementById('kv-results').innerText = 'Fetching keys...';
95
+ const res = await fetch('/api/kv');
96
+ const json = await res.json();
97
+ document.getElementById('kv-results').innerText = JSON.stringify(json, null, 2);
98
+ }
99
+
100
+ async function loadR2() {
101
+ document.getElementById('r2-results').innerText = 'Fetching objects...';
102
+ const res = await fetch('/api/bucket');
103
+ const json = await res.json();
104
+ document.getElementById('r2-results').innerText = JSON.stringify(json, null, 2);
105
+ }
106
+ </script>
107
+ </body>
108
+ </html>
109
+ \`;
110
+
111
+ export default {
112
+ async fetch(req, env) {
113
+ const url = new URL(req.url);
114
+
115
+ if (req.method === 'POST' && url.pathname === '/api/db') {
116
+ if (!env.DB) return Response.json({ error: "DB binding not configured in emulator" }, { status: 400 });
117
+ const body = await req.json();
118
+ try {
119
+ const res = await env.DB.prepare(body.query).all();
120
+ return Response.json(res);
121
+ } catch(e) {
122
+ return Response.json({ error: e.message }, { status: 400 });
123
+ }
124
+ }
125
+
126
+ if (req.method === 'GET' && url.pathname === '/api/kv') {
127
+ if (!env.__RAW_KV) return Response.json({ error: "KV binding not configured in emulator" }, { status: 400 });
128
+ try {
129
+ const res = await env.__RAW_KV.list();
130
+ return Response.json(res);
131
+ } catch(e) {
132
+ return Response.json({ error: e.message }, { status: 400 });
133
+ }
134
+ }
135
+
136
+ if (req.method === 'GET' && url.pathname === '/api/bucket') {
137
+ if (!env.R2) return Response.json({ error: "BUCKET binding not configured in emulator" }, { status: 400 });
138
+ try {
139
+ const res = await env.R2.list();
140
+ return Response.json(res);
141
+ } catch(e) {
142
+ return Response.json({ error: e.message }, { status: 400 });
143
+ }
144
+ }
145
+
146
+ // Default to the Editor UI Payload
147
+ return new Response(UI_HTML, { headers: { 'Content-Type': 'text/html; charset=utf-8' }});
148
+ }
149
+ };
@@ -3,8 +3,8 @@ import * as UserCode from '${outFileUrl}';
3
3
 
4
4
  export default {
5
5
  async fetch(req, env, ctx) {
6
- // Isolate the global KV
7
- const { __RAW_KV, ...safeEnv } = env;
6
+ // Isolate the global KV and Storage
7
+ const { __RAW_KV, R2, ...safeEnv } = env;
8
8
  const KV_PREFIX = \`${workspaceId}:${projectId}:\`;
9
9
 
10
10
  // 1. Air-Gapped Virtual KV Multiplexer
@@ -80,9 +80,12 @@ export default {
80
80
  const Channels = {
81
81
  accept: async (roomName, metadata) => {
82
82
  console.log(\`\\x1b[36m[\${env.PROJECT_ID || '${projectId}'}]\\x1b[0m 🔌 Channels.accept: [\${roomName}]\`, metadata);
83
- const req = new Request("http://platform/.internal", { headers: { Upgrade: "websocket" }});
83
+ if (req.headers.get("Upgrade") !== "websocket") {
84
+ return new Response("Upgrade Required: Endpoint expects a WebSocket connection.", { status: 426 });
85
+ }
86
+ const doReq = new Request("http://platform/.internal", { headers: req.headers });
84
87
  const stub = env.CHANNEL_DO.idFromName(roomName);
85
- return await env.CHANNEL_DO.get(stub).fetch(req);
88
+ return await env.CHANNEL_DO.get(stub).fetch(doReq);
86
89
  },
87
90
  broadcast: async (roomName, payload) => {
88
91
  console.log(\`\\x1b[36m[\${env.PROJECT_ID || '${projectId}'}]\\x1b[0m 📡 Channels.broadcast: [\${roomName}]\`, payload);
@@ -110,6 +113,7 @@ export default {
110
113
  const customEnv = {
111
114
  ...safeEnv,
112
115
  KV: virtualKV,
116
+ BUCKET: R2,
113
117
  Tasks,
114
118
  Channels,
115
119
  Discord,