@pixelbyte-software/pixcode 1.39.2 → 1.40.1
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/assets/{index-3kCM_yzd.js → index-DuWeuJ35.js} +164 -163
- package/dist/assets/index-jVI_Oaw-.css +32 -0
- package/dist/index.html +2 -2
- package/dist/landing.html +1 -1
- package/dist-server/server/index.js +4 -0
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/routes/live-view.js +170 -0
- package/dist-server/server/routes/live-view.js.map +1 -0
- package/dist-server/server/services/live-view.js +517 -0
- package/dist-server/server/services/live-view.js.map +1 -0
- package/package.json +1 -1
- package/scripts/smoke/changes-panel-layout.mjs +2 -2
- package/scripts/smoke/chat-message-timeline-order.mjs +41 -0
- package/scripts/smoke/live-view-integration.mjs +137 -0
- package/server/index.js +5 -0
- package/server/routes/live-view.js +189 -0
- package/server/services/live-view.js +560 -0
- package/dist/assets/index-DoOHFfYP.css +0 -32
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
|
|
7
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
8
|
+
const read = async (relativePath) => {
|
|
9
|
+
const { readFile } = await import('node:fs/promises');
|
|
10
|
+
return readFile(path.join(repoRoot, relativePath), 'utf8');
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const appTypes = await read('src/types/app.ts');
|
|
14
|
+
assert.ok(
|
|
15
|
+
appTypes.includes("'liveView'"),
|
|
16
|
+
'AppTab should include the Live View tab.',
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const projectsState = await read('src/hooks/useProjectsState.ts');
|
|
20
|
+
assert.ok(
|
|
21
|
+
projectsState.includes("'liveView'"),
|
|
22
|
+
'Persisted tab validation should allow Live View.',
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const tabSwitcher = await read('src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx');
|
|
26
|
+
assert.ok(
|
|
27
|
+
/id:\s*'liveView'/.test(tabSwitcher),
|
|
28
|
+
'Main tab switcher should render Live View after Changes.',
|
|
29
|
+
);
|
|
30
|
+
assert.ok(
|
|
31
|
+
tabSwitcher.indexOf("id: 'changes'") < tabSwitcher.indexOf("id: 'liveView'"),
|
|
32
|
+
'Live View should be placed after Changes.',
|
|
33
|
+
);
|
|
34
|
+
assert.ok(
|
|
35
|
+
/sidePanelTabs\s*=\s*new Set<AppTab>\(\[[^\]]*'liveView'/.test(tabSwitcher),
|
|
36
|
+
'Live View should use the same split/full side-panel behavior as Files, Source Control, and Changes.',
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const mainContent = await read('src/components/main-content/view/MainContent.tsx');
|
|
40
|
+
assert.ok(
|
|
41
|
+
mainContent.includes('LiveViewPanel'),
|
|
42
|
+
'MainContent should render the Live View panel.',
|
|
43
|
+
);
|
|
44
|
+
assert.ok(
|
|
45
|
+
/sidePanelTabs\s*=\s*new Set<AppTab>\(\[[^\]]*'liveView'/.test(mainContent),
|
|
46
|
+
'MainContent should classify Live View as a side panel instead of a full main tab.',
|
|
47
|
+
);
|
|
48
|
+
assert.ok(
|
|
49
|
+
/renderSidePanel\s*=\s*\(tab:[^)]*'liveView'/.test(mainContent),
|
|
50
|
+
'MainContent should render Live View from renderSidePanel.',
|
|
51
|
+
);
|
|
52
|
+
assert.ok(
|
|
53
|
+
!mainContent.includes("activeTab === 'liveView' && ("),
|
|
54
|
+
'Live View must not render as a full-width primary tab.',
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const liveViewPanel = await read('src/components/live-view/LiveViewPanel.tsx');
|
|
58
|
+
assert.ok(
|
|
59
|
+
liveViewPanel.includes("action === 'stop'"),
|
|
60
|
+
'Live View stop should clear the active iframe session instead of keeping the stopped /live share path.',
|
|
61
|
+
);
|
|
62
|
+
assert.ok(
|
|
63
|
+
liveViewPanel.includes("setStatus({"),
|
|
64
|
+
'Live View stop should write a fresh stopped state.',
|
|
65
|
+
);
|
|
66
|
+
assert.ok(
|
|
67
|
+
liveViewPanel.includes('VIEWPORT_PRESETS'),
|
|
68
|
+
'Live View should expose desktop, tablet, mobile, and custom viewport presets.',
|
|
69
|
+
);
|
|
70
|
+
assert.ok(
|
|
71
|
+
liveViewPanel.includes("type=\"number\""),
|
|
72
|
+
'Live View should let users edit the preview resolution width and height.',
|
|
73
|
+
);
|
|
74
|
+
assert.ok(
|
|
75
|
+
liveViewPanel.includes('viewportSize.width') && liveViewPanel.includes('viewportSize.height'),
|
|
76
|
+
'Live View iframe should use the selected preview resolution.',
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const serverIndex = await read('server/index.js');
|
|
80
|
+
assert.ok(
|
|
81
|
+
serverIndex.includes("app.use('/api/live-view', authenticateToken, liveViewRoutes)"),
|
|
82
|
+
'Live View protected API should be mounted.',
|
|
83
|
+
);
|
|
84
|
+
assert.ok(
|
|
85
|
+
serverIndex.includes("app.use('/live', createLiveViewPublicRouter())"),
|
|
86
|
+
'Live View public share proxy should be mounted.',
|
|
87
|
+
);
|
|
88
|
+
assert.ok(
|
|
89
|
+
serverIndex.indexOf("app.use('/live', createLiveViewPublicRouter())") < serverIndex.indexOf("express.static(path.join(APP_ROOT, 'dist')"),
|
|
90
|
+
'Live View public proxy must be mounted before static app fallback.',
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const {
|
|
94
|
+
detectLiveViewTarget,
|
|
95
|
+
getLiveViewState,
|
|
96
|
+
startLiveView,
|
|
97
|
+
stopLiveView,
|
|
98
|
+
} = await import('../../server/services/live-view.js');
|
|
99
|
+
const workspace = await mkdtemp(path.join(tmpdir(), 'pixcode-live-view-smoke-'));
|
|
100
|
+
const staticProject = path.join(workspace, 'static');
|
|
101
|
+
const viteProject = path.join(workspace, 'vite');
|
|
102
|
+
const djangoProject = path.join(workspace, 'django');
|
|
103
|
+
await writeFile(path.join(staticProject, 'index.html'), '<main>hello</main>', { recursive: true }).catch(async (error) => {
|
|
104
|
+
if (error.code !== 'ENOENT') throw error;
|
|
105
|
+
const { mkdir } = await import('node:fs/promises');
|
|
106
|
+
await mkdir(staticProject, { recursive: true });
|
|
107
|
+
await writeFile(path.join(staticProject, 'index.html'), '<main>hello</main>');
|
|
108
|
+
});
|
|
109
|
+
const { mkdir } = await import('node:fs/promises');
|
|
110
|
+
await mkdir(viteProject, { recursive: true });
|
|
111
|
+
await writeFile(path.join(viteProject, 'package.json'), JSON.stringify({
|
|
112
|
+
scripts: { dev: 'vite --host 0.0.0.0' },
|
|
113
|
+
dependencies: { vite: '^7.0.0' },
|
|
114
|
+
}, null, 2));
|
|
115
|
+
await mkdir(djangoProject, { recursive: true });
|
|
116
|
+
await writeFile(path.join(djangoProject, 'manage.py'), '#!/usr/bin/env python\n');
|
|
117
|
+
|
|
118
|
+
const staticTarget = await detectLiveViewTarget(staticProject);
|
|
119
|
+
assert.equal(staticTarget.available, true, 'Static HTML projects should be available.');
|
|
120
|
+
assert.equal(staticTarget.kind, 'static', 'Static HTML projects should use direct static serving.');
|
|
121
|
+
|
|
122
|
+
const viteTarget = await detectLiveViewTarget(viteProject);
|
|
123
|
+
assert.equal(viteTarget.available, true, 'Vite projects should be detected.');
|
|
124
|
+
assert.equal(viteTarget.command?.id, 'npm-dev-vite', 'Vite projects should get a Vite-aware command.');
|
|
125
|
+
|
|
126
|
+
const djangoTarget = await detectLiveViewTarget(djangoProject);
|
|
127
|
+
assert.equal(djangoTarget.available, true, 'Django projects should be detected from manage.py.');
|
|
128
|
+
assert.equal(djangoTarget.command?.id, 'python-django', 'Django projects should get a runserver command.');
|
|
129
|
+
|
|
130
|
+
const staticSession = await startLiveView('static-smoke', staticProject);
|
|
131
|
+
assert.equal(staticSession.status, 'running', 'Static Live View should start without a child process.');
|
|
132
|
+
assert.match(staticSession.sharePath, /^\/live\/[a-f0-9]{24}\/$/, 'Live View should expose a random public share path.');
|
|
133
|
+
const staticState = await getLiveViewState('static-smoke', staticProject);
|
|
134
|
+
assert.equal(staticState.session?.shareId, staticSession.shareId, 'Live View state should retain the active share session.');
|
|
135
|
+
await stopLiveView('static-smoke');
|
|
136
|
+
|
|
137
|
+
console.log('live view integration smoke passed');
|
package/server/index.js
CHANGED
|
@@ -78,6 +78,7 @@ import messagesRoutes from './routes/messages.js';
|
|
|
78
78
|
import diagnosticsRoutes from './routes/diagnostics.js';
|
|
79
79
|
import remoteRoutes from './routes/remote.js';
|
|
80
80
|
import publicApiRoutes from './routes/public-api.js';
|
|
81
|
+
import liveViewRoutes, { createLiveViewPublicRouter } from './routes/live-view.js';
|
|
81
82
|
import providerRoutes from './modules/providers/provider.routes.js';
|
|
82
83
|
import {
|
|
83
84
|
createA2ARouter,
|
|
@@ -405,6 +406,9 @@ app.use('/api/remote', authenticateToken, remoteRoutes);
|
|
|
405
406
|
// Public automation manifest (protected so private host details only go to signed-in clients)
|
|
406
407
|
app.use('/api/public', authenticateToken, publicApiRoutes);
|
|
407
408
|
|
|
409
|
+
// Project Live View (protected control API + public share proxy)
|
|
410
|
+
app.use('/api/live-view', authenticateToken, liveViewRoutes);
|
|
411
|
+
|
|
408
412
|
// Unified provider MCP routes (protected)
|
|
409
413
|
app.use('/api/providers', authenticateToken, providerRoutes);
|
|
410
414
|
|
|
@@ -419,6 +423,7 @@ app.use('/a2a', createA2ARouter());
|
|
|
419
423
|
app.use('/preview', authenticateToken, createPreviewProxyRouter());
|
|
420
424
|
app.use('/api/orchestration', authenticateToken, createOrchestrationTaskRouter());
|
|
421
425
|
app.use('/api/orchestration', authenticateToken, createWorkflowRouter());
|
|
426
|
+
app.use('/live', createLiveViewPublicRouter());
|
|
422
427
|
|
|
423
428
|
// Network discovery / QR endpoints (protected)
|
|
424
429
|
app.use('/api/network', authenticateToken, networkRoutes);
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import express from 'express';
|
|
5
|
+
|
|
6
|
+
import { extractProjectDirectory } from '../projects.js';
|
|
7
|
+
import { getTunnelState } from '../services/external-access.js';
|
|
8
|
+
import {
|
|
9
|
+
getLiveViewSessionByShareId,
|
|
10
|
+
getLiveViewState,
|
|
11
|
+
restartLiveView,
|
|
12
|
+
startLiveView,
|
|
13
|
+
stopLiveView,
|
|
14
|
+
} from '../services/live-view.js';
|
|
15
|
+
|
|
16
|
+
const router = express.Router();
|
|
17
|
+
|
|
18
|
+
function requestBaseUrl(req) {
|
|
19
|
+
return `${req.protocol}://${req.get('host')}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildUrls(req, session) {
|
|
23
|
+
if (!session?.sharePath) return { local: null, external: null, preferred: null };
|
|
24
|
+
const local = `${requestBaseUrl(req)}${session.sharePath}`;
|
|
25
|
+
const tunnel = getTunnelState();
|
|
26
|
+
const external = tunnel?.running && tunnel.url ? `${tunnel.url}${session.sharePath}` : null;
|
|
27
|
+
return {
|
|
28
|
+
local,
|
|
29
|
+
external,
|
|
30
|
+
preferred: external || local,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function resolveProjectPath(projectName) {
|
|
35
|
+
const projectPath = await extractProjectDirectory(projectName);
|
|
36
|
+
if (!projectPath || typeof projectPath !== 'string') {
|
|
37
|
+
const error = new Error('Project path could not be resolved.');
|
|
38
|
+
error.statusCode = 404;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
return projectPath;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
router.get('/:projectName/status', async (req, res) => {
|
|
45
|
+
try {
|
|
46
|
+
const { projectName } = req.params;
|
|
47
|
+
const projectPath = await resolveProjectPath(projectName);
|
|
48
|
+
const state = await getLiveViewState(projectName, projectPath);
|
|
49
|
+
res.json({
|
|
50
|
+
...state,
|
|
51
|
+
urls: buildUrls(req, state.session),
|
|
52
|
+
tunnel: getTunnelState(),
|
|
53
|
+
});
|
|
54
|
+
} catch (error) {
|
|
55
|
+
res.status(error.statusCode || 500).json({ error: error.message || 'Failed to read Live View state' });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
router.post('/:projectName/start', async (req, res) => {
|
|
60
|
+
try {
|
|
61
|
+
const { projectName } = req.params;
|
|
62
|
+
const projectPath = await resolveProjectPath(projectName);
|
|
63
|
+
const session = await startLiveView(projectName, projectPath, req.body || {});
|
|
64
|
+
res.json({
|
|
65
|
+
success: true,
|
|
66
|
+
session,
|
|
67
|
+
urls: buildUrls(req, session),
|
|
68
|
+
tunnel: getTunnelState(),
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const status = error.code === 'LIVE_VIEW_NOT_AVAILABLE' ? 422 : 500;
|
|
72
|
+
res.status(status).json({ error: error.message || 'Failed to start Live View' });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
router.post('/:projectName/restart', async (req, res) => {
|
|
77
|
+
try {
|
|
78
|
+
const { projectName } = req.params;
|
|
79
|
+
const projectPath = await resolveProjectPath(projectName);
|
|
80
|
+
const session = await restartLiveView(projectName, projectPath, req.body || {});
|
|
81
|
+
res.json({
|
|
82
|
+
success: true,
|
|
83
|
+
session,
|
|
84
|
+
urls: buildUrls(req, session),
|
|
85
|
+
tunnel: getTunnelState(),
|
|
86
|
+
});
|
|
87
|
+
} catch (error) {
|
|
88
|
+
res.status(500).json({ error: error.message || 'Failed to restart Live View' });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
router.post('/:projectName/stop', async (req, res) => {
|
|
93
|
+
try {
|
|
94
|
+
const session = await stopLiveView(req.params.projectName);
|
|
95
|
+
res.json({
|
|
96
|
+
success: true,
|
|
97
|
+
session,
|
|
98
|
+
urls: buildUrls(req, session),
|
|
99
|
+
tunnel: getTunnelState(),
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
res.status(500).json({ error: error.message || 'Failed to stop Live View' });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function resolveStaticFile(staticRoot, requestUrl) {
|
|
107
|
+
const parsed = new URL(requestUrl, 'http://pixcode.local');
|
|
108
|
+
const rawPath = decodeURIComponent(parsed.pathname || '/');
|
|
109
|
+
const relativePath = rawPath.replace(/^\/+/, '') || 'index.html';
|
|
110
|
+
const root = path.resolve(staticRoot);
|
|
111
|
+
const candidate = path.resolve(root, relativePath);
|
|
112
|
+
const relative = path.relative(root, candidate);
|
|
113
|
+
|
|
114
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
115
|
+
const error = new Error('Live View path is outside the static root.');
|
|
116
|
+
error.statusCode = 403;
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return candidate;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function sendStaticLiveView(req, res, session) {
|
|
124
|
+
const root = session.staticRoot;
|
|
125
|
+
let filePath = resolveStaticFile(root, req.url);
|
|
126
|
+
try {
|
|
127
|
+
const stats = await fs.stat(filePath);
|
|
128
|
+
if (stats.isDirectory()) {
|
|
129
|
+
filePath = path.join(filePath, 'index.html');
|
|
130
|
+
}
|
|
131
|
+
await fs.access(filePath);
|
|
132
|
+
} catch {
|
|
133
|
+
filePath = path.join(root, 'index.html');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
137
|
+
res.sendFile(filePath);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function proxyLiveView(req, res, session) {
|
|
141
|
+
if (!session.upstreamUrl) {
|
|
142
|
+
res.status(503).json({ error: 'Live View upstream is not ready yet.' });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const targetUrl = new URL(req.url || '/', session.upstreamUrl);
|
|
147
|
+
try {
|
|
148
|
+
const upstream = await fetch(targetUrl, {
|
|
149
|
+
method: req.method,
|
|
150
|
+
headers: {
|
|
151
|
+
accept: req.header('accept') || '*/*',
|
|
152
|
+
'user-agent': req.header('user-agent') || 'pixcode-live-view',
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
res.status(upstream.status);
|
|
156
|
+
upstream.headers.forEach((value, key) => {
|
|
157
|
+
const lower = key.toLowerCase();
|
|
158
|
+
if (lower === 'content-encoding' || lower === 'content-length') return;
|
|
159
|
+
if (lower === 'x-frame-options' || lower === 'content-security-policy') return;
|
|
160
|
+
res.setHeader(key, value);
|
|
161
|
+
});
|
|
162
|
+
res.send(Buffer.from(await upstream.arrayBuffer()));
|
|
163
|
+
} catch (error) {
|
|
164
|
+
res.status(502).json({ error: error.message || 'Live View proxy failed' });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function createLiveViewPublicRouter() {
|
|
169
|
+
const publicRouter = express.Router();
|
|
170
|
+
|
|
171
|
+
publicRouter.use('/:shareId', async (req, res) => {
|
|
172
|
+
const session = getLiveViewSessionByShareId(req.params.shareId);
|
|
173
|
+
if (!session || session.status === 'stopped' || session.status === 'error') {
|
|
174
|
+
res.status(404).json({ error: 'Live View session not found.' });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (session.kind === 'static') {
|
|
179
|
+
await sendStaticLiveView(req, res, session);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await proxyLiveView(req, res, session);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return publicRouter;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export default router;
|