castle-web-cli 0.1.0 → 0.2.0

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/AGENTS.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  ## Commands
4
4
 
5
- - `castle-web init <dir>` — scaffold a new project (index.html, game.js, castle.js SDK)
6
- - `castle-web serve [dir] [--port PORT] [--open]` — local dev server with hot reload (default port 3737)
7
- - `castle-web push [dir]` — bundle with esbuild and publish to Castle. Creates a new deck if no castle.json exists.
5
+ - `castle-web init <dir>` — scaffold a new project (index.html, game.js, package.json with castle-web-sdk)
6
+ - `castle-web serve [dir] [--port PORT] [--open]` — local dev server with Vite (default port 3737, WS on 3738). No auto-reload — use `restart` to reload.
7
+ - `castle-web restart [--port PORT]` — reload the game in the browser (default WS port 3738)
8
+ - `castle-web screenshot [--out FILE] [--port PORT]` — capture a screenshot from the running game (default: screenshot.png)
9
+ - `castle-web push [dir]` — bundle with Vite and publish to Castle. Creates a new deck if no castle.json exists.
8
10
  - `castle-web login` — authenticate with Castle (saves token to ~/.castle/config.json)
9
11
 
10
12
  ## Project Structure
@@ -13,10 +15,29 @@
13
15
  my-game/
14
16
  index.html # entry point
15
17
  game.js # game code
16
- castle.js # SDK (copied by init)
18
+ package.json # dependencies (castle-web-sdk)
17
19
  castle.json # deck/card IDs (created by first push)
20
+ .castle/ # runtime state (logs, screenshots)
21
+ logs.txt # forwarded console.log/warn/error
22
+ screenshots/ # captured screenshots
18
23
  ```
19
24
 
20
- ## Bundling
25
+ ## SDK
21
26
 
22
- `push` uses esbuild to inline all `<script src="...">` tags into a single self-contained HTML string stored in the deck's scene data as `experimentalWeb.bundle`.
27
+ Games import and call `setup()` + `initCard()`:
28
+
29
+ ```js
30
+ import { setup, initCard } from 'castle-web-sdk';
31
+ setup();
32
+ const card = initCard();
33
+ ```
34
+
35
+ `setup()` enables log forwarding, screenshot capture, and live reload via local WebSocket. `initCard()` creates a 5:7 card container.
36
+
37
+ ## Agent Workflow
38
+
39
+ 1. `castle-web serve` in a background tmux window
40
+ 2. Edit code
41
+ 3. `castle-web restart` to reload
42
+ 4. `castle-web screenshot --out shot.png` to see the result
43
+ 5. Read `.castle/logs.txt` for console output
package/dist/api.js CHANGED
@@ -41,7 +41,7 @@ export async function pollForCLILogin(pollToken) {
41
41
  }
42
42
  export async function me() {
43
43
  try {
44
- const data = await graphql(`query { me { userId username isAnonymous } }`);
44
+ const data = await graphql(`query { me { userId username isAnonymous photo { url } photoFrame { frameUrl } } }`);
45
45
  return data.data?.me ?? null;
46
46
  }
47
47
  catch {
package/dist/bundle.d.ts CHANGED
@@ -1 +1 @@
1
- export declare function bundleProject(dir: string): string;
1
+ export declare function bundleProject(dir: string): Promise<string>;
package/dist/bundle.js CHANGED
@@ -1,27 +1,25 @@
1
- import * as fs from 'fs';
2
1
  import * as path from 'path';
3
- import * as esbuild from 'esbuild';
4
- export function bundleProject(dir) {
5
- const indexPath = path.join(dir, 'index.html');
6
- if (!fs.existsSync(indexPath)) {
7
- throw new Error(`No index.html found in ${dir}`);
8
- }
9
- let html = fs.readFileSync(indexPath, 'utf-8');
10
- // Find <script src="..."> tags, bundle each with esbuild, inline the result
11
- html = html.replace(/<script\s+([^>]*?)src=["']([^"']+)["']([^>]*?)>\s*<\/script>/gi, (fullMatch, before, src, after) => {
12
- const srcPath = path.resolve(dir, src);
13
- if (!fs.existsSync(srcPath))
14
- return fullMatch;
15
- const result = esbuild.buildSync({
16
- entryPoints: [srcPath],
17
- bundle: true,
18
- format: 'esm',
2
+ import { build } from 'vite';
3
+ import { viteSingleFile } from 'vite-plugin-singlefile';
4
+ export async function bundleProject(dir) {
5
+ const result = await build({
6
+ root: dir,
7
+ plugins: [viteSingleFile()],
8
+ build: {
19
9
  write: false,
20
- minify: false,
21
- });
22
- const code = result.outputFiles[0].text;
23
- const attrs = `${before}${after}`.trim();
24
- return `<script type="module"${attrs ? ' ' + attrs : ''}>\n${code}</script>`;
10
+ rollupOptions: {
11
+ input: path.join(dir, 'index.html'),
12
+ },
13
+ },
14
+ logLevel: 'silent',
25
15
  });
26
- return html;
16
+ const output = Array.isArray(result) ? result[0] : result;
17
+ if (!('output' in output))
18
+ throw new Error('Unexpected build result');
19
+ for (const chunk of output.output) {
20
+ if (chunk.type === 'asset' && chunk.fileName.endsWith('.html')) {
21
+ return chunk.source;
22
+ }
23
+ }
24
+ throw new Error('No HTML output found from build');
27
25
  }
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import * as fs from 'fs';
2
3
  import { login } from './login.js';
3
4
  import { serve } from './serve.js';
4
5
  import { push } from './push.js';
@@ -9,6 +10,8 @@ function usage() {
9
10
  console.log(`Usage:
10
11
  castle-web init <dir>
11
12
  castle-web serve [dir] [--port PORT] [--open]
13
+ castle-web restart [--port PORT]
14
+ castle-web screenshot [--out FILE] [--port PORT]
12
15
  castle-web push [dir]
13
16
  castle-web login
14
17
 
@@ -39,6 +42,50 @@ async function main() {
39
42
  await push(dir);
40
43
  break;
41
44
  }
45
+ case 'restart': {
46
+ const portIdx = args.indexOf('--port');
47
+ const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
48
+ const { default: WS } = await import('ws');
49
+ const ws = new WS(`ws://localhost:${wsPort}`);
50
+ ws.on('open', () => {
51
+ ws.send(JSON.stringify({ type: 'restart' }));
52
+ setTimeout(() => { ws.close(); process.exit(0); }, 100);
53
+ });
54
+ ws.on('error', () => { console.error('Could not connect. Is castle-web serve running?'); process.exit(1); });
55
+ break;
56
+ }
57
+ case 'screenshot': {
58
+ const outIdx = args.indexOf('--out');
59
+ const outFile = outIdx >= 0 ? args[outIdx + 1] : 'screenshot.png';
60
+ const portIdx = args.indexOf('--port');
61
+ const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
62
+ const requestId = Math.random().toString(36).slice(2);
63
+ const { default: WS } = await import('ws');
64
+ const ws = new WS(`ws://localhost:${wsPort}`);
65
+ ws.on('open', () => {
66
+ ws.send(JSON.stringify({ type: 'screenshot_request', requestId }));
67
+ });
68
+ const timeout = setTimeout(() => {
69
+ console.error('Screenshot timed out. Is castle-web serve running?');
70
+ ws.close();
71
+ process.exit(1);
72
+ }, 3000);
73
+ ws.on('message', (raw) => {
74
+ try {
75
+ const msg = JSON.parse(raw.toString());
76
+ if (msg.type === 'screenshot_response' && msg.requestId === requestId) {
77
+ clearTimeout(timeout);
78
+ const base64 = msg.data.replace(/^data:image\/png;base64,/, '');
79
+ fs.writeFileSync(outFile, Buffer.from(base64, 'base64'));
80
+ console.log(`Saved ${outFile}`);
81
+ ws.close();
82
+ process.exit(0);
83
+ }
84
+ }
85
+ catch { }
86
+ });
87
+ break;
88
+ }
42
89
  case 'login':
43
90
  await login();
44
91
  break;
package/dist/init.js CHANGED
@@ -11,19 +11,19 @@ const INDEX_HTML = `<!DOCTYPE html>
11
11
  </body>
12
12
  </html>
13
13
  `;
14
- const GAME_JS = `import * as castle from 'castle-web-sdk';
14
+ const GAME_JS = `import { setup, initCard } from 'castle-web-sdk';
15
15
 
16
- castle.enableHotReload();
17
- const card = castle.initCard();
16
+ setup();
17
+ const card = initCard();
18
18
 
19
- const div = document.createElement('div');
20
- div.style.cssText = \`
19
+ const el = document.createElement('div');
20
+ el.style.cssText = \`
21
21
  width: 100%; height: 100%;
22
22
  display: flex; align-items: center; justify-content: center;
23
23
  font-family: system-ui; color: #fff; font-size: 24px;
24
24
  \`;
25
- div.textContent = 'Hello Castle!';
26
- card.appendChild(div);
25
+ el.textContent = 'Hello Castle!';
26
+ card.appendChild(el);
27
27
  `;
28
28
  export async function init(dir) {
29
29
  const projectDir = path.resolve(dir);
package/dist/push.js CHANGED
@@ -39,8 +39,12 @@ export async function push(dir) {
39
39
  }
40
40
  let castleJson = readCastleJson(projectDir);
41
41
  console.log('Bundling...');
42
- const bundle = bundleProject(projectDir);
43
- console.log(`Bundle size: ${(bundle.length / 1024).toFixed(1)}KB`);
42
+ const bundle = await bundleProject(projectDir);
43
+ const sizeKB = bundle.length / 1024;
44
+ console.log(`Bundle size: ${sizeKB.toFixed(1)}KB`);
45
+ if (sizeKB > 1024) {
46
+ console.warn(`Warning: Bundle is ${(sizeKB / 1024).toFixed(1)}MB. Large bundles may load slowly.`);
47
+ }
44
48
  const sceneData = buildSceneData(bundle);
45
49
  const isNew = !castleJson;
46
50
  const cardId = castleJson?.cardId ?? nanoid(12);
@@ -69,7 +73,7 @@ export async function push(dir) {
69
73
  // Create or update via updateCardAndDeckV2
70
74
  const title = path.basename(path.resolve(projectDir));
71
75
  try {
72
- const result = await api.updateCardAndDeckV2({ deckId, title }, { cardId, blocks: [], uploadId: uploadConfig.uploadId, makeInitialCard: true });
76
+ const result = await api.updateCardAndDeckV2({ deckId, title, visibility: 'unlisted' }, { cardId, blocks: [], uploadId: uploadConfig.uploadId, makeInitialCard: true });
73
77
  if (isNew) {
74
78
  const newCastleJson = { deckId: result.deckId, cardId: result.cardId };
75
79
  fs.writeFileSync(path.join(projectDir, 'castle.json'), JSON.stringify(newCastleJson, null, 2) + '\n', 'utf-8');
package/dist/serve.js CHANGED
@@ -1,120 +1,97 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import * as http from 'http';
4
- const MIME_TYPES = {
5
- '.html': 'text/html',
6
- '.js': 'application/javascript',
7
- '.mjs': 'application/javascript',
8
- '.css': 'text/css',
9
- '.json': 'application/json',
10
- '.png': 'image/png',
11
- '.jpg': 'image/jpeg',
12
- '.gif': 'image/gif',
13
- '.svg': 'image/svg+xml',
14
- '.wasm': 'application/wasm',
15
- '.ico': 'image/x-icon',
16
- '.mp3': 'audio/mpeg',
17
- '.ogg': 'audio/ogg',
18
- '.wav': 'audio/wav',
19
- '.mp4': 'video/mp4',
20
- '.webm': 'video/webm',
21
- '.ttf': 'font/ttf',
22
- '.woff': 'font/woff',
23
- '.woff2': 'font/woff2',
24
- };
3
+ import { createServer } from 'vite';
4
+ import { WebSocketServer, WebSocket } from 'ws';
5
+ function castlePlugin(wsPort) {
6
+ return {
7
+ name: 'castle-dev',
8
+ configureServer(server) {
9
+ server.middlewares.use((req, res, next) => {
10
+ if (req.url === '/__castle/ws-port') {
11
+ res.writeHead(200, { 'Content-Type': 'application/json' });
12
+ res.end(JSON.stringify({ port: wsPort }));
13
+ return;
14
+ }
15
+ next();
16
+ });
17
+ },
18
+ };
19
+ }
25
20
  export async function serve(dir, options = {}) {
26
21
  const projectDir = path.resolve(dir);
27
22
  if (!fs.existsSync(projectDir)) {
28
23
  console.error(`Directory not found: ${projectDir}`);
29
24
  process.exit(1);
30
25
  }
31
- const indexPath = path.join(projectDir, 'index.html');
32
- if (!fs.existsSync(indexPath)) {
26
+ if (!fs.existsSync(path.join(projectDir, 'index.html'))) {
33
27
  console.error(`No index.html found in ${projectDir}`);
34
28
  process.exit(1);
35
29
  }
36
30
  const port = parseInt(options.port ?? '3737', 10);
37
- let version = 0;
38
- const pendingVersionPolls = [];
39
- function flushVersionPolls() {
40
- while (pendingVersionPolls.length > 0) {
41
- const poll = pendingVersionPolls.shift();
42
- poll.res.writeHead(200, { 'Content-Type': 'application/json' });
43
- poll.res.end(JSON.stringify({ version }));
44
- }
45
- }
46
- // Watch project dir for changes
47
- fs.watch(projectDir, { recursive: true }, (event, filename) => {
48
- if (filename && !filename.startsWith('.')) {
49
- version++;
50
- flushVersionPolls();
51
- }
31
+ const wsPort = port + 1;
32
+ // Local WebSocket server for SDK communication
33
+ const castleDir = path.join(projectDir, '.castle');
34
+ if (!fs.existsSync(castleDir))
35
+ fs.mkdirSync(castleDir, { recursive: true });
36
+ const logFile = path.join(castleDir, 'logs.txt');
37
+ const screenshotsDir = path.join(castleDir, 'screenshots');
38
+ startWSServer(wsPort, logFile, screenshotsDir);
39
+ const vite = await createServer({
40
+ root: projectDir,
41
+ plugins: [castlePlugin(wsPort)],
42
+ server: {
43
+ port,
44
+ strictPort: true,
45
+ open: options.open ? true : undefined,
46
+ hmr: false,
47
+ fs: { strict: false },
48
+ },
49
+ logLevel: 'info',
52
50
  });
53
- const server = http.createServer((req, res) => {
54
- const url = new URL(req.url ?? '/', `http://localhost:${port}`);
55
- const pathname = url.pathname;
56
- // Version polling for hot reload
57
- if (pathname === '/__version') {
58
- const clientVersion = parseInt(url.searchParams.get('v') ?? '0', 10);
59
- if (clientVersion < version) {
60
- res.writeHead(200, { 'Content-Type': 'application/json' });
61
- res.end(JSON.stringify({ version }));
62
- }
63
- else {
64
- // Long poll wait for next change
65
- pendingVersionPolls.push({ clientVersion, res });
66
- const timeout = setTimeout(() => {
67
- const idx = pendingVersionPolls.findIndex((p) => p.res === res);
68
- if (idx >= 0)
69
- pendingVersionPolls.splice(idx, 1);
70
- res.writeHead(200, { 'Content-Type': 'application/json' });
71
- res.end(JSON.stringify({ version }));
72
- }, 30000);
73
- res.on('close', () => {
74
- clearTimeout(timeout);
75
- const idx = pendingVersionPolls.findIndex((p) => p.res === res);
76
- if (idx >= 0)
77
- pendingVersionPolls.splice(idx, 1);
78
- });
79
- }
80
- return;
81
- }
82
- // Serve static files
83
- let filePath = path.join(projectDir, pathname === '/' ? 'index.html' : pathname);
84
- filePath = path.normalize(filePath);
85
- // Security: don't serve files outside project dir
86
- if (!filePath.startsWith(projectDir)) {
87
- res.writeHead(403);
88
- res.end('Forbidden');
89
- return;
90
- }
91
- if (!fs.existsSync(filePath)) {
92
- res.writeHead(404);
93
- res.end('Not found');
94
- return;
95
- }
96
- const stat = fs.statSync(filePath);
97
- if (stat.isDirectory()) {
98
- filePath = path.join(filePath, 'index.html');
99
- if (!fs.existsSync(filePath)) {
100
- res.writeHead(404);
101
- res.end('Not found');
102
- return;
51
+ await vite.listen();
52
+ vite.printUrls();
53
+ }
54
+ function startWSServer(port, logFile, screenshotsDir) {
55
+ const wss = new WebSocketServer({ port });
56
+ const clients = new Set();
57
+ wss.on('connection', (ws) => {
58
+ clients.add(ws);
59
+ ws.on('message', (raw) => {
60
+ try {
61
+ const msg = JSON.parse(raw.toString());
62
+ if (msg.type === 'restart' || msg.type === 'screenshot_request') {
63
+ const fwd = JSON.stringify(msg);
64
+ for (const c of clients) {
65
+ if (c !== ws && c.readyState === WebSocket.OPEN)
66
+ c.send(fwd);
67
+ }
68
+ }
69
+ if (msg.type === 'log') {
70
+ const line = `[${msg.level}] ${msg.msg}\n`;
71
+ fs.appendFileSync(logFile, line);
72
+ if (msg.level === 'error')
73
+ process.stderr.write(line);
74
+ else
75
+ process.stdout.write(line);
76
+ }
77
+ if (msg.type === 'screenshot_response') {
78
+ if (!fs.existsSync(screenshotsDir))
79
+ fs.mkdirSync(screenshotsDir, { recursive: true });
80
+ const filename = `screenshot-${Date.now()}.png`;
81
+ const filepath = path.join(screenshotsDir, filename);
82
+ const base64 = msg.data.replace(/^data:image\/png;base64,/, '');
83
+ fs.writeFileSync(filepath, Buffer.from(base64, 'base64'));
84
+ console.log(`Screenshot saved: ${filepath}`);
85
+ const fwd = JSON.stringify(msg);
86
+ for (const c of clients) {
87
+ if (c !== ws && c.readyState === WebSocket.OPEN)
88
+ c.send(fwd);
89
+ }
90
+ }
103
91
  }
104
- }
105
- const ext = path.extname(filePath).toLowerCase();
106
- const contentType = MIME_TYPES[ext] ?? 'application/octet-stream';
107
- const data = fs.readFileSync(filePath);
108
- res.writeHead(200, { 'Content-Type': contentType });
109
- res.end(data);
110
- });
111
- server.listen(port, () => {
112
- const url = `http://localhost:${port}`;
113
- console.log(`Serving ${projectDir}`);
114
- console.log(` ${url}`);
115
- console.log('Press Ctrl+C to stop.');
116
- if (options.open) {
117
- import('open').then(({ default: open }) => open(url));
118
- }
92
+ catch { }
93
+ });
94
+ ws.on('close', () => { clients.delete(ws); });
119
95
  });
96
+ return wss;
120
97
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"
@@ -10,12 +10,15 @@
10
10
  "dev": "tsc --watch"
11
11
  },
12
12
  "dependencies": {
13
- "esbuild": "^0.28.0",
14
13
  "nanoid": "^5.1.7",
15
- "open": "^10.0.0"
14
+ "open": "^10.0.0",
15
+ "vite": "^8.0.3",
16
+ "vite-plugin-singlefile": "^2.3.2",
17
+ "ws": "^8.20.0"
16
18
  },
17
19
  "devDependencies": {
18
20
  "@types/node": "^20.0.0",
21
+ "@types/ws": "^8.18.1",
19
22
  "typescript": "^5.0.0"
20
23
  }
21
24
  }
package/src/api.ts CHANGED
@@ -49,7 +49,7 @@ export async function pollForCLILogin(pollToken: string): Promise<any> {
49
49
 
50
50
  export async function me(): Promise<any> {
51
51
  try {
52
- const data = await graphql(`query { me { userId username isAnonymous } }`);
52
+ const data = await graphql(`query { me { userId username isAnonymous photo { url } photoFrame { frameUrl } } }`);
53
53
  return data.data?.me ?? null;
54
54
  } catch {
55
55
  return null;
package/src/bundle.ts CHANGED
@@ -1,35 +1,28 @@
1
- import * as fs from 'fs';
2
1
  import * as path from 'path';
3
- import * as esbuild from 'esbuild';
2
+ import { build } from 'vite';
3
+ import { viteSingleFile } from 'vite-plugin-singlefile';
4
4
 
5
- export function bundleProject(dir: string): string {
6
- const indexPath = path.join(dir, 'index.html');
7
- if (!fs.existsSync(indexPath)) {
8
- throw new Error(`No index.html found in ${dir}`);
9
- }
10
-
11
- let html = fs.readFileSync(indexPath, 'utf-8');
5
+ export async function bundleProject(dir: string): Promise<string> {
6
+ const result = await build({
7
+ root: dir,
8
+ plugins: [viteSingleFile()],
9
+ build: {
10
+ write: false,
11
+ rollupOptions: {
12
+ input: path.join(dir, 'index.html'),
13
+ },
14
+ },
15
+ logLevel: 'silent',
16
+ });
12
17
 
13
- // Find <script src="..."> tags, bundle each with esbuild, inline the result
14
- html = html.replace(
15
- /<script\s+([^>]*?)src=["']([^"']+)["']([^>]*?)>\s*<\/script>/gi,
16
- (fullMatch, before, src, after) => {
17
- const srcPath = path.resolve(dir, src);
18
- if (!fs.existsSync(srcPath)) return fullMatch;
18
+ const output = Array.isArray(result) ? result[0] : result;
19
+ if (!('output' in output)) throw new Error('Unexpected build result');
19
20
 
20
- const result = esbuild.buildSync({
21
- entryPoints: [srcPath],
22
- bundle: true,
23
- format: 'esm',
24
- write: false,
25
- minify: false,
26
- });
27
-
28
- const code = result.outputFiles[0].text;
29
- const attrs = `${before}${after}`.trim();
30
- return `<script type="module"${attrs ? ' ' + attrs : ''}>\n${code}</script>`;
21
+ for (const chunk of output.output) {
22
+ if (chunk.type === 'asset' && chunk.fileName.endsWith('.html')) {
23
+ return chunk.source as string;
31
24
  }
32
- );
25
+ }
33
26
 
34
- return html;
27
+ throw new Error('No HTML output found from build');
35
28
  }
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import * as fs from 'fs';
3
4
  import { login } from './login.js';
4
5
  import { serve } from './serve.js';
5
6
  import { push } from './push.js';
@@ -12,6 +13,8 @@ function usage() {
12
13
  console.log(`Usage:
13
14
  castle-web init <dir>
14
15
  castle-web serve [dir] [--port PORT] [--open]
16
+ castle-web restart [--port PORT]
17
+ castle-web screenshot [--out FILE] [--port PORT]
15
18
  castle-web push [dir]
16
19
  castle-web login
17
20
 
@@ -40,6 +43,49 @@ async function main() {
40
43
  await push(dir);
41
44
  break;
42
45
  }
46
+ case 'restart': {
47
+ const portIdx = args.indexOf('--port');
48
+ const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
49
+ const { default: WS } = await import('ws');
50
+ const ws = new WS(`ws://localhost:${wsPort}`);
51
+ ws.on('open', () => {
52
+ ws.send(JSON.stringify({ type: 'restart' }));
53
+ setTimeout(() => { ws.close(); process.exit(0); }, 100);
54
+ });
55
+ ws.on('error', () => { console.error('Could not connect. Is castle-web serve running?'); process.exit(1); });
56
+ break;
57
+ }
58
+ case 'screenshot': {
59
+ const outIdx = args.indexOf('--out');
60
+ const outFile = outIdx >= 0 ? args[outIdx + 1] : 'screenshot.png';
61
+ const portIdx = args.indexOf('--port');
62
+ const wsPort = parseInt(portIdx >= 0 ? args[portIdx + 1] : '3738', 10);
63
+ const requestId = Math.random().toString(36).slice(2);
64
+ const { default: WS } = await import('ws');
65
+ const ws = new WS(`ws://localhost:${wsPort}`);
66
+ ws.on('open', () => {
67
+ ws.send(JSON.stringify({ type: 'screenshot_request', requestId }));
68
+ });
69
+ const timeout = setTimeout(() => {
70
+ console.error('Screenshot timed out. Is castle-web serve running?');
71
+ ws.close();
72
+ process.exit(1);
73
+ }, 3000);
74
+ ws.on('message', (raw: Buffer) => {
75
+ try {
76
+ const msg = JSON.parse(raw.toString());
77
+ if (msg.type === 'screenshot_response' && msg.requestId === requestId) {
78
+ clearTimeout(timeout);
79
+ const base64 = msg.data.replace(/^data:image\/png;base64,/, '');
80
+ fs.writeFileSync(outFile, Buffer.from(base64, 'base64'));
81
+ console.log(`Saved ${outFile}`);
82
+ ws.close();
83
+ process.exit(0);
84
+ }
85
+ } catch {}
86
+ });
87
+ break;
88
+ }
43
89
  case 'login':
44
90
  await login();
45
91
  break;
package/src/init.ts CHANGED
@@ -13,19 +13,19 @@ const INDEX_HTML = `<!DOCTYPE html>
13
13
  </html>
14
14
  `;
15
15
 
16
- const GAME_JS = `import * as castle from 'castle-web-sdk';
16
+ const GAME_JS = `import { setup, initCard } from 'castle-web-sdk';
17
17
 
18
- castle.enableHotReload();
19
- const card = castle.initCard();
18
+ setup();
19
+ const card = initCard();
20
20
 
21
- const div = document.createElement('div');
22
- div.style.cssText = \`
21
+ const el = document.createElement('div');
22
+ el.style.cssText = \`
23
23
  width: 100%; height: 100%;
24
24
  display: flex; align-items: center; justify-content: center;
25
25
  font-family: system-ui; color: #fff; font-size: 24px;
26
26
  \`;
27
- div.textContent = 'Hello Castle!';
28
- card.appendChild(div);
27
+ el.textContent = 'Hello Castle!';
28
+ card.appendChild(el);
29
29
  `;
30
30
 
31
31
  export async function init(dir: string) {
package/src/push.ts CHANGED
@@ -49,8 +49,12 @@ export async function push(dir: string) {
49
49
  let castleJson = readCastleJson(projectDir);
50
50
 
51
51
  console.log('Bundling...');
52
- const bundle = bundleProject(projectDir);
53
- console.log(`Bundle size: ${(bundle.length / 1024).toFixed(1)}KB`);
52
+ const bundle = await bundleProject(projectDir);
53
+ const sizeKB = bundle.length / 1024;
54
+ console.log(`Bundle size: ${sizeKB.toFixed(1)}KB`);
55
+ if (sizeKB > 1024) {
56
+ console.warn(`Warning: Bundle is ${(sizeKB / 1024).toFixed(1)}MB. Large bundles may load slowly.`);
57
+ }
54
58
 
55
59
  const sceneData = buildSceneData(bundle);
56
60
  const isNew = !castleJson;
@@ -85,7 +89,7 @@ export async function push(dir: string) {
85
89
  const title = path.basename(path.resolve(projectDir));
86
90
  try {
87
91
  const result = await api.updateCardAndDeckV2(
88
- { deckId, title },
92
+ { deckId, title, visibility: 'unlisted' },
89
93
  { cardId, blocks: [], uploadId: uploadConfig.uploadId, makeInitialCard: true }
90
94
  );
91
95
 
package/src/serve.ts CHANGED
@@ -1,28 +1,23 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import * as http from 'http';
3
+ import { createServer, type Plugin } from 'vite';
4
+ import { WebSocketServer, WebSocket } from 'ws';
4
5
 
5
- const MIME_TYPES: Record<string, string> = {
6
- '.html': 'text/html',
7
- '.js': 'application/javascript',
8
- '.mjs': 'application/javascript',
9
- '.css': 'text/css',
10
- '.json': 'application/json',
11
- '.png': 'image/png',
12
- '.jpg': 'image/jpeg',
13
- '.gif': 'image/gif',
14
- '.svg': 'image/svg+xml',
15
- '.wasm': 'application/wasm',
16
- '.ico': 'image/x-icon',
17
- '.mp3': 'audio/mpeg',
18
- '.ogg': 'audio/ogg',
19
- '.wav': 'audio/wav',
20
- '.mp4': 'video/mp4',
21
- '.webm': 'video/webm',
22
- '.ttf': 'font/ttf',
23
- '.woff': 'font/woff',
24
- '.woff2': 'font/woff2',
25
- };
6
+ function castlePlugin(wsPort: number): Plugin {
7
+ return {
8
+ name: 'castle-dev',
9
+ configureServer(server) {
10
+ server.middlewares.use((req, res, next) => {
11
+ if (req.url === '/__castle/ws-port') {
12
+ res.writeHead(200, { 'Content-Type': 'application/json' });
13
+ res.end(JSON.stringify({ port: wsPort }));
14
+ return;
15
+ }
16
+ next();
17
+ });
18
+ },
19
+ };
20
+ }
26
21
 
27
22
  export async function serve(
28
23
  dir: string,
@@ -33,103 +28,81 @@ export async function serve(
33
28
  console.error(`Directory not found: ${projectDir}`);
34
29
  process.exit(1);
35
30
  }
36
-
37
- const indexPath = path.join(projectDir, 'index.html');
38
- if (!fs.existsSync(indexPath)) {
31
+ if (!fs.existsSync(path.join(projectDir, 'index.html'))) {
39
32
  console.error(`No index.html found in ${projectDir}`);
40
33
  process.exit(1);
41
34
  }
42
35
 
43
36
  const port = parseInt(options.port ?? '3737', 10);
44
- let version = 0;
45
- const pendingVersionPolls: Array<{ clientVersion: number; res: http.ServerResponse }> = [];
37
+ const wsPort = port + 1;
46
38
 
47
- function flushVersionPolls() {
48
- while (pendingVersionPolls.length > 0) {
49
- const poll = pendingVersionPolls.shift()!;
50
- poll.res.writeHead(200, { 'Content-Type': 'application/json' });
51
- poll.res.end(JSON.stringify({ version }));
52
- }
53
- }
39
+ // Local WebSocket server for SDK communication
40
+ const castleDir = path.join(projectDir, '.castle');
41
+ if (!fs.existsSync(castleDir)) fs.mkdirSync(castleDir, { recursive: true });
42
+ const logFile = path.join(castleDir, 'logs.txt');
43
+ const screenshotsDir = path.join(castleDir, 'screenshots');
54
44
 
55
- // Watch project dir for changes
56
- fs.watch(projectDir, { recursive: true }, (event, filename) => {
57
- if (filename && !filename.startsWith('.')) {
58
- version++;
59
- flushVersionPolls();
60
- }
61
- });
45
+ startWSServer(wsPort, logFile, screenshotsDir);
62
46
 
63
- const server = http.createServer((req, res) => {
64
- const url = new URL(req.url ?? '/', `http://localhost:${port}`);
65
- const pathname = url.pathname;
47
+ const vite = await createServer({
48
+ root: projectDir,
49
+ plugins: [castlePlugin(wsPort)],
50
+ server: {
51
+ port,
52
+ strictPort: true,
53
+ open: options.open ? true : undefined,
54
+ hmr: false,
55
+ fs: { strict: false },
56
+ },
57
+ logLevel: 'info',
58
+ });
66
59
 
67
- // Version polling for hot reload
68
- if (pathname === '/__version') {
69
- const clientVersion = parseInt(url.searchParams.get('v') ?? '0', 10);
70
- if (clientVersion < version) {
71
- res.writeHead(200, { 'Content-Type': 'application/json' });
72
- res.end(JSON.stringify({ version }));
73
- } else {
74
- // Long poll — wait for next change
75
- pendingVersionPolls.push({ clientVersion, res });
76
- const timeout = setTimeout(() => {
77
- const idx = pendingVersionPolls.findIndex((p) => p.res === res);
78
- if (idx >= 0) pendingVersionPolls.splice(idx, 1);
79
- res.writeHead(200, { 'Content-Type': 'application/json' });
80
- res.end(JSON.stringify({ version }));
81
- }, 30000);
82
- res.on('close', () => {
83
- clearTimeout(timeout);
84
- const idx = pendingVersionPolls.findIndex((p) => p.res === res);
85
- if (idx >= 0) pendingVersionPolls.splice(idx, 1);
86
- });
87
- }
88
- return;
89
- }
60
+ await vite.listen();
61
+ vite.printUrls();
62
+ }
90
63
 
91
- // Serve static files
92
- let filePath = path.join(projectDir, pathname === '/' ? 'index.html' : pathname);
93
- filePath = path.normalize(filePath);
64
+ function startWSServer(port: number, logFile: string, screenshotsDir: string): WebSocketServer {
65
+ const wss = new WebSocketServer({ port });
66
+ const clients = new Set<WebSocket>();
94
67
 
95
- // Security: don't serve files outside project dir
96
- if (!filePath.startsWith(projectDir)) {
97
- res.writeHead(403);
98
- res.end('Forbidden');
99
- return;
100
- }
68
+ wss.on('connection', (ws) => {
69
+ clients.add(ws);
101
70
 
102
- if (!fs.existsSync(filePath)) {
103
- res.writeHead(404);
104
- res.end('Not found');
105
- return;
106
- }
71
+ ws.on('message', (raw) => {
72
+ try {
73
+ const msg = JSON.parse(raw.toString());
107
74
 
108
- const stat = fs.statSync(filePath);
109
- if (stat.isDirectory()) {
110
- filePath = path.join(filePath, 'index.html');
111
- if (!fs.existsSync(filePath)) {
112
- res.writeHead(404);
113
- res.end('Not found');
114
- return;
115
- }
116
- }
75
+ if (msg.type === 'restart' || msg.type === 'screenshot_request') {
76
+ const fwd = JSON.stringify(msg);
77
+ for (const c of clients) {
78
+ if (c !== ws && c.readyState === WebSocket.OPEN) c.send(fwd);
79
+ }
80
+ }
117
81
 
118
- const ext = path.extname(filePath).toLowerCase();
119
- const contentType = MIME_TYPES[ext] ?? 'application/octet-stream';
120
- const data = fs.readFileSync(filePath);
121
- res.writeHead(200, { 'Content-Type': contentType });
122
- res.end(data);
123
- });
82
+ if (msg.type === 'log') {
83
+ const line = `[${msg.level}] ${msg.msg}\n`;
84
+ fs.appendFileSync(logFile, line);
85
+ if (msg.level === 'error') process.stderr.write(line);
86
+ else process.stdout.write(line);
87
+ }
124
88
 
125
- server.listen(port, () => {
126
- const url = `http://localhost:${port}`;
127
- console.log(`Serving ${projectDir}`);
128
- console.log(` ${url}`);
129
- console.log('Press Ctrl+C to stop.');
89
+ if (msg.type === 'screenshot_response') {
90
+ if (!fs.existsSync(screenshotsDir)) fs.mkdirSync(screenshotsDir, { recursive: true });
91
+ const filename = `screenshot-${Date.now()}.png`;
92
+ const filepath = path.join(screenshotsDir, filename);
93
+ const base64 = msg.data.replace(/^data:image\/png;base64,/, '');
94
+ fs.writeFileSync(filepath, Buffer.from(base64, 'base64'));
95
+ console.log(`Screenshot saved: ${filepath}`);
96
+ const fwd = JSON.stringify(msg);
97
+ for (const c of clients) {
98
+ if (c !== ws && c.readyState === WebSocket.OPEN) c.send(fwd);
99
+ }
100
+ }
101
+ } catch {}
102
+ });
130
103
 
131
- if (options.open) {
132
- import('open').then(({ default: open }) => open(url));
133
- }
104
+ ws.on('close', () => { clients.delete(ws); });
134
105
  });
106
+
107
+ return wss;
135
108
  }