@antigenic-oss/paint 0.1.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/LICENSE +178 -0
- package/NOTICE +4 -0
- package/README.md +180 -0
- package/bin/paint.js +266 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +19 -0
- package/package.json +81 -0
- package/postcss.config.mjs +8 -0
- package/public/dev-editor-inspector.js +1872 -0
- package/src/app/api/claude/analyze/route.ts +319 -0
- package/src/app/api/claude/apply/route.ts +185 -0
- package/src/app/api/claude/pick-folder/route.ts +64 -0
- package/src/app/api/claude/scan/route.ts +221 -0
- package/src/app/api/claude/status/route.ts +55 -0
- package/src/app/api/project/scan/route.ts +634 -0
- package/src/app/api/project-scan/css-variables/route.ts +238 -0
- package/src/app/api/project-scan/route.ts +40 -0
- package/src/app/api/project-scan/tailwind-config/route.ts +172 -0
- package/src/app/api/proxy/[[...path]]/route.ts +2400 -0
- package/src/app/docs/DocsClient.tsx +322 -0
- package/src/app/docs/layout.tsx +7 -0
- package/src/app/docs/page.tsx +855 -0
- package/src/app/globals.css +176 -0
- package/src/app/layout.tsx +19 -0
- package/src/app/page.tsx +46 -0
- package/src/bridge/api-handlers.ts +885 -0
- package/src/bridge/proxy-handler.ts +329 -0
- package/src/bridge/server.ts +113 -0
- package/src/components/BreakpointTabs.tsx +72 -0
- package/src/components/ChangeSummaryModal.tsx +267 -0
- package/src/components/ConnectModal.tsx +994 -0
- package/src/components/Editor.tsx +90 -0
- package/src/components/PageSelector.tsx +208 -0
- package/src/components/PreviewFrame.tsx +299 -0
- package/src/components/ProjectFolderBanner.tsx +91 -0
- package/src/components/ResponsiveToolbar.tsx +222 -0
- package/src/components/TargetSelector.tsx +243 -0
- package/src/components/TopBar.tsx +315 -0
- package/src/components/common/CollapsibleSection.tsx +36 -0
- package/src/components/common/ColorPicker.tsx +920 -0
- package/src/components/common/EditablePre.tsx +136 -0
- package/src/components/common/ErrorBoundary.tsx +65 -0
- package/src/components/common/ResizablePanel.tsx +83 -0
- package/src/components/common/ScanAnimation.tsx +76 -0
- package/src/components/common/ToastContainer.tsx +97 -0
- package/src/components/common/UnitInput.tsx +77 -0
- package/src/components/common/VariableColorPicker.tsx +622 -0
- package/src/components/left-panel/AddElementPanel.tsx +237 -0
- package/src/components/left-panel/ComponentsPanel.tsx +609 -0
- package/src/components/left-panel/IconSidebar.tsx +99 -0
- package/src/components/left-panel/LayerNode.tsx +874 -0
- package/src/components/left-panel/LayerSearch.tsx +23 -0
- package/src/components/left-panel/LayersPanel.tsx +52 -0
- package/src/components/left-panel/LeftPanel.tsx +122 -0
- package/src/components/left-panel/PagesPanel.tsx +114 -0
- package/src/components/left-panel/icons.tsx +162 -0
- package/src/components/left-panel/terminal/ScanOverlay.tsx +66 -0
- package/src/components/left-panel/terminal/TerminalPanel.tsx +217 -0
- package/src/components/right-panel/ElementLogBox.tsx +248 -0
- package/src/components/right-panel/PanelTabs.tsx +83 -0
- package/src/components/right-panel/RightPanel.tsx +41 -0
- package/src/components/right-panel/changes/AiScanResultPanel.tsx +285 -0
- package/src/components/right-panel/changes/ChangeEntry.tsx +59 -0
- package/src/components/right-panel/changes/ChangelogActions.tsx +105 -0
- package/src/components/right-panel/changes/ChangesPanel.tsx +1474 -0
- package/src/components/right-panel/claude/ApplyConfirmModal.tsx +376 -0
- package/src/components/right-panel/claude/ClaudeErrorState.tsx +125 -0
- package/src/components/right-panel/claude/ClaudeIntegrationPanel.tsx +482 -0
- package/src/components/right-panel/claude/ClaudeProgressIndicator.tsx +76 -0
- package/src/components/right-panel/claude/DiffCard.tsx +130 -0
- package/src/components/right-panel/claude/DiffViewer.tsx +54 -0
- package/src/components/right-panel/claude/ProjectRootSelector.tsx +275 -0
- package/src/components/right-panel/claude/ResultsSummary.tsx +119 -0
- package/src/components/right-panel/claude/SetupFlow.tsx +315 -0
- package/src/components/right-panel/console/ConsolePanel.tsx +209 -0
- package/src/components/right-panel/design/AppearanceSection.tsx +703 -0
- package/src/components/right-panel/design/BackgroundSection.tsx +516 -0
- package/src/components/right-panel/design/BorderSection.tsx +161 -0
- package/src/components/right-panel/design/CSSRawView.tsx +412 -0
- package/src/components/right-panel/design/DesignCSSTabToggle.tsx +51 -0
- package/src/components/right-panel/design/DesignPanel.tsx +275 -0
- package/src/components/right-panel/design/ElementBreadcrumb.tsx +51 -0
- package/src/components/right-panel/design/GradientEditor.tsx +726 -0
- package/src/components/right-panel/design/LayoutSection.tsx +1948 -0
- package/src/components/right-panel/design/PositionSection.tsx +865 -0
- package/src/components/right-panel/design/PropertiesSection.tsx +86 -0
- package/src/components/right-panel/design/SVGSection.tsx +361 -0
- package/src/components/right-panel/design/ShadowBlurSection.tsx +227 -0
- package/src/components/right-panel/design/SizeSection.tsx +183 -0
- package/src/components/right-panel/design/TextSection.tsx +719 -0
- package/src/components/right-panel/design/icons.tsx +948 -0
- package/src/components/right-panel/design/inputs/BoxModelPreview.tsx +467 -0
- package/src/components/right-panel/design/inputs/ColorInput.tsx +43 -0
- package/src/components/right-panel/design/inputs/CompactInput.tsx +333 -0
- package/src/components/right-panel/design/inputs/DraggableLabel.tsx +118 -0
- package/src/components/right-panel/design/inputs/IconToggleGroup.tsx +54 -0
- package/src/components/right-panel/design/inputs/LinkedInputPair.tsx +174 -0
- package/src/components/right-panel/design/inputs/SectionHeader.tsx +79 -0
- package/src/components/right-panel/variables/VariablesPanel.tsx +388 -0
- package/src/hooks/useBridge.ts +95 -0
- package/src/hooks/useChangeTracker.ts +563 -0
- package/src/hooks/useClaudeAPI.ts +118 -0
- package/src/hooks/useDOMTree.ts +25 -0
- package/src/hooks/useKeyboardShortcuts.ts +76 -0
- package/src/hooks/usePostMessage.ts +589 -0
- package/src/hooks/useProjectScan.ts +204 -0
- package/src/hooks/useResizable.ts +20 -0
- package/src/hooks/useSelectedElement.ts +51 -0
- package/src/hooks/useTargetUrl.ts +81 -0
- package/src/inspector/DOMTraverser.ts +71 -0
- package/src/inspector/ElementSelector.ts +23 -0
- package/src/inspector/HoverHighlighter.ts +54 -0
- package/src/inspector/SelectionHighlighter.ts +27 -0
- package/src/inspector/StyleExtractor.ts +19 -0
- package/src/inspector/inspector.ts +17 -0
- package/src/inspector/messaging.ts +30 -0
- package/src/lib/apiBase.ts +15 -0
- package/src/lib/classifyElement.ts +430 -0
- package/src/lib/claude-bin.ts +197 -0
- package/src/lib/claude-stream.ts +158 -0
- package/src/lib/clientProjectScanner.ts +344 -0
- package/src/lib/componentMatcher.ts +156 -0
- package/src/lib/constants.ts +573 -0
- package/src/lib/cssVariableUtils.ts +409 -0
- package/src/lib/diffParser.ts +206 -0
- package/src/lib/folderPicker.ts +84 -0
- package/src/lib/gradientParser.ts +160 -0
- package/src/lib/projectScanner.ts +355 -0
- package/src/lib/promptBuilder.ts +402 -0
- package/src/lib/shadowParser.ts +124 -0
- package/src/lib/tailwindClassParser.ts +248 -0
- package/src/lib/textShadowUtils.ts +106 -0
- package/src/lib/utils.ts +299 -0
- package/src/lib/validatePath.ts +40 -0
- package/src/proxy.ts +92 -0
- package/src/server/terminal-server.ts +104 -0
- package/src/store/changeSlice.ts +288 -0
- package/src/store/claudeSlice.ts +222 -0
- package/src/store/componentSlice.ts +90 -0
- package/src/store/consoleSlice.ts +51 -0
- package/src/store/cssVariableSlice.ts +94 -0
- package/src/store/elementSlice.ts +78 -0
- package/src/store/index.ts +35 -0
- package/src/store/terminalSlice.ts +30 -0
- package/src/store/treeSlice.ts +69 -0
- package/src/store/uiSlice.ts +327 -0
- package/src/types/changelog.ts +49 -0
- package/src/types/claude.ts +131 -0
- package/src/types/component.ts +49 -0
- package/src/types/cssVariables.ts +18 -0
- package/src/types/element.ts +21 -0
- package/src/types/file-system-access.d.ts +27 -0
- package/src/types/gradient.ts +12 -0
- package/src/types/messages.ts +392 -0
- package/src/types/shadow.ts +8 -0
- package/src/types/tree.ts +9 -0
- package/tsconfig.json +42 -0
- package/tsconfig.server.json +12 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge proxy handler.
|
|
3
|
+
*
|
|
4
|
+
* Simplified version of /api/proxy/[[...path]]/route.ts.
|
|
5
|
+
* Since the bridge IS the iframe origin, relative URLs naturally
|
|
6
|
+
* route back through it — eliminates most URL rewriting.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const PROXY_HEADER = 'x-dev-editor-target'
|
|
10
|
+
|
|
11
|
+
// In-memory target URL (set on first request with the query param)
|
|
12
|
+
let storedTargetUrl: string | null = null
|
|
13
|
+
|
|
14
|
+
const SECURITY_HEADERS_TO_STRIP = new Set([
|
|
15
|
+
'content-security-policy',
|
|
16
|
+
'content-security-policy-report-only',
|
|
17
|
+
'x-frame-options',
|
|
18
|
+
'cross-origin-embedder-policy',
|
|
19
|
+
'cross-origin-opener-policy',
|
|
20
|
+
'cross-origin-resource-policy',
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
const HMR_PATTERNS = [
|
|
24
|
+
'.hot-update.',
|
|
25
|
+
'webpack-hmr',
|
|
26
|
+
'turbopack-hmr',
|
|
27
|
+
'__webpack_hmr',
|
|
28
|
+
'_next/webpack-hmr',
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const SCRIPT_STRIP_RE =
|
|
32
|
+
/<script\b(?![^>]*type\s*=\s*["']application\/ld\+json["'])[^>]*>[\s\S]*?<\/script\s*>/gi
|
|
33
|
+
const SCRIPT_SELF_CLOSING_RE =
|
|
34
|
+
/<script\b(?![^>]*type\s*=\s*["']application\/ld\+json["'])[^>]*\/\s*>/gi
|
|
35
|
+
|
|
36
|
+
function isLocalhostUrl(url: string): boolean {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = new URL(url)
|
|
39
|
+
return parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1'
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getTargetUrl(req: Request, url: URL): string | null {
|
|
46
|
+
// Check query param first
|
|
47
|
+
const fromQuery = url.searchParams.get(PROXY_HEADER)
|
|
48
|
+
if (fromQuery && isLocalhostUrl(fromQuery)) {
|
|
49
|
+
storedTargetUrl = fromQuery
|
|
50
|
+
return fromQuery
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check header
|
|
54
|
+
const fromHeader = req.headers.get(PROXY_HEADER)
|
|
55
|
+
if (fromHeader && isLocalhostUrl(fromHeader)) {
|
|
56
|
+
storedTargetUrl = fromHeader
|
|
57
|
+
return fromHeader
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fall back to stored target
|
|
61
|
+
return storedTargetUrl
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isHtmlResponse(contentType: string | null): boolean {
|
|
65
|
+
if (!contentType) return false
|
|
66
|
+
return contentType.includes('text/html')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isCssResponse(contentType: string | null): boolean {
|
|
70
|
+
if (!contentType) return false
|
|
71
|
+
return contentType.includes('text/css')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildNavigationBlocker(): string {
|
|
75
|
+
return `<script>
|
|
76
|
+
(function() {
|
|
77
|
+
// HMR mock — prevent target app's HMR from connecting
|
|
78
|
+
var _origWS = window.WebSocket;
|
|
79
|
+
window.WebSocket = function(url) {
|
|
80
|
+
var ws = { readyState: 3, send: function(){}, close: function(){},
|
|
81
|
+
addEventListener: function(){}, removeEventListener: function(){},
|
|
82
|
+
onopen: null, onclose: null, onerror: null, onmessage: null,
|
|
83
|
+
CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 };
|
|
84
|
+
setTimeout(function() { if (ws.onerror) ws.onerror(new Event('error')); }, 50);
|
|
85
|
+
return ws;
|
|
86
|
+
};
|
|
87
|
+
window.WebSocket.CONNECTING = 0;
|
|
88
|
+
window.WebSocket.OPEN = 1;
|
|
89
|
+
window.WebSocket.CLOSING = 2;
|
|
90
|
+
window.WebSocket.CLOSED = 3;
|
|
91
|
+
|
|
92
|
+
// Mock EventSource (Turbopack HMR)
|
|
93
|
+
var _origES = window.EventSource;
|
|
94
|
+
window.EventSource = function() {
|
|
95
|
+
var es = { readyState: 2, close: function(){},
|
|
96
|
+
addEventListener: function(){}, removeEventListener: function(){},
|
|
97
|
+
onopen: null, onerror: null, onmessage: null,
|
|
98
|
+
CONNECTING: 0, OPEN: 1, CLOSED: 2 };
|
|
99
|
+
setTimeout(function() { if (es.onerror) es.onerror(new Event('error')); }, 50);
|
|
100
|
+
return es;
|
|
101
|
+
};
|
|
102
|
+
window.EventSource.CONNECTING = 0;
|
|
103
|
+
window.EventSource.OPEN = 1;
|
|
104
|
+
window.EventSource.CLOSED = 2;
|
|
105
|
+
|
|
106
|
+
// Suppress unhandled rejection errors from HMR
|
|
107
|
+
window.addEventListener('unhandledrejection', function(e) {
|
|
108
|
+
var msg = e.reason && (e.reason.message || String(e.reason));
|
|
109
|
+
if (msg && (/hmr|hot.update|webpack|turbopack/i.test(msg))) {
|
|
110
|
+
e.preventDefault();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Prevent navigation loops — block location changes
|
|
115
|
+
var _reloadCount = 0;
|
|
116
|
+
var _origReload = window.location.reload;
|
|
117
|
+
window.location.reload = function() {
|
|
118
|
+
_reloadCount++;
|
|
119
|
+
if (_reloadCount > 2) return;
|
|
120
|
+
_origReload.call(window.location);
|
|
121
|
+
};
|
|
122
|
+
})();
|
|
123
|
+
</script>`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function injectIntoHtml(html: string, inspectorScript: string | null): string {
|
|
127
|
+
// Strip all script tags (except ld+json)
|
|
128
|
+
let result = html.replace(SCRIPT_STRIP_RE, '')
|
|
129
|
+
result = result.replace(SCRIPT_SELF_CLOSING_RE, '')
|
|
130
|
+
|
|
131
|
+
const navBlocker = buildNavigationBlocker()
|
|
132
|
+
|
|
133
|
+
// Inspector injection
|
|
134
|
+
const inspectorTag = inspectorScript
|
|
135
|
+
? `<script>${inspectorScript}</script>`
|
|
136
|
+
: '<script src="/dev-editor-inspector.js"></script>'
|
|
137
|
+
|
|
138
|
+
// Inject navigation blocker in <head>, inspector before </body>
|
|
139
|
+
if (result.includes('</head>')) {
|
|
140
|
+
result = result.replace('</head>', navBlocker + '</head>')
|
|
141
|
+
} else {
|
|
142
|
+
result = navBlocker + result
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (result.includes('</body>')) {
|
|
146
|
+
result = result.replace('</body>', inspectorTag + '</body>')
|
|
147
|
+
} else {
|
|
148
|
+
result = result + inspectorTag
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function stripSecurityHeaders(headers: Headers): Headers {
|
|
155
|
+
const cleaned = new Headers()
|
|
156
|
+
headers.forEach((value, key) => {
|
|
157
|
+
if (!SECURITY_HEADERS_TO_STRIP.has(key.toLowerCase())) {
|
|
158
|
+
cleaned.append(key, value)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
return cleaned
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function rewriteCssUrls(css: string, targetOrigin: string): string {
|
|
165
|
+
// Rewrite absolute URLs pointing to the target to be relative (go through bridge)
|
|
166
|
+
return css.replace(
|
|
167
|
+
new RegExp(
|
|
168
|
+
`url\\(\\s*['"]?${targetOrigin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(/[^'")\\s]*)['"]?\\s*\\)`,
|
|
169
|
+
'g',
|
|
170
|
+
),
|
|
171
|
+
'url($1)',
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function handleProxy(
|
|
176
|
+
req: Request,
|
|
177
|
+
url: URL,
|
|
178
|
+
cors: Record<string, string>,
|
|
179
|
+
inspectorScript: string | null,
|
|
180
|
+
): Promise<Response> {
|
|
181
|
+
const targetUrl = getTargetUrl(req, url)
|
|
182
|
+
|
|
183
|
+
if (!targetUrl) {
|
|
184
|
+
return Response.json(
|
|
185
|
+
{
|
|
186
|
+
error:
|
|
187
|
+
'No target URL. Add ?x-dev-editor-target=http://localhost:PORT to your first request.',
|
|
188
|
+
},
|
|
189
|
+
{ status: 400, headers: cors },
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Short-circuit HMR requests
|
|
194
|
+
const pathname = url.pathname
|
|
195
|
+
if (HMR_PATTERNS.some((p) => pathname.includes(p))) {
|
|
196
|
+
if (pathname.endsWith('.json')) {
|
|
197
|
+
return new Response('{}', {
|
|
198
|
+
headers: { ...cors, 'Content-Type': 'application/json' },
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
if (pathname.endsWith('.js')) {
|
|
202
|
+
return new Response('', {
|
|
203
|
+
headers: { ...cors, 'Content-Type': 'application/javascript' },
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
return new Response(null, { status: 204, headers: cors })
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Build the target fetch URL
|
|
210
|
+
const targetOrigin = new URL(targetUrl).origin
|
|
211
|
+
const fetchUrl = `${targetOrigin}${pathname}${url.search ? url.search.replace(new RegExp(`[?&]${PROXY_HEADER}=[^&]*`), '') : ''}`
|
|
212
|
+
|
|
213
|
+
// Strip the proxy header from the search params
|
|
214
|
+
const cleanSearch = url.search
|
|
215
|
+
.replace(new RegExp(`[?&]${PROXY_HEADER}=[^&]*`), '')
|
|
216
|
+
.replace(/^\?$/, '')
|
|
217
|
+
|
|
218
|
+
const finalFetchUrl = `${targetOrigin}${pathname}${cleanSearch}`
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const targetRes = await fetch(finalFetchUrl, {
|
|
222
|
+
method: req.method,
|
|
223
|
+
headers: {
|
|
224
|
+
Accept: req.headers.get('accept') || '*/*',
|
|
225
|
+
'Accept-Encoding': 'identity',
|
|
226
|
+
},
|
|
227
|
+
redirect: 'manual',
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
// Handle redirects — rewrite Location to stay within bridge
|
|
231
|
+
if (targetRes.status >= 300 && targetRes.status < 400) {
|
|
232
|
+
const location = targetRes.headers.get('location')
|
|
233
|
+
if (location) {
|
|
234
|
+
let rewrittenLocation = location
|
|
235
|
+
if (location.startsWith(targetOrigin)) {
|
|
236
|
+
rewrittenLocation = location.slice(targetOrigin.length)
|
|
237
|
+
}
|
|
238
|
+
const redirectHeaders = new Headers(cors)
|
|
239
|
+
redirectHeaders.set('Location', rewrittenLocation)
|
|
240
|
+
// Preserve cookies
|
|
241
|
+
targetRes.headers.forEach((value, key) => {
|
|
242
|
+
if (key.toLowerCase() === 'set-cookie') {
|
|
243
|
+
redirectHeaders.append('Set-Cookie', value)
|
|
244
|
+
}
|
|
245
|
+
})
|
|
246
|
+
return new Response(null, {
|
|
247
|
+
status: targetRes.status,
|
|
248
|
+
headers: redirectHeaders,
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const contentType = targetRes.headers.get('content-type')
|
|
254
|
+
const responseHeaders = stripSecurityHeaders(targetRes.headers)
|
|
255
|
+
|
|
256
|
+
// Add CORS headers
|
|
257
|
+
for (const [key, value] of Object.entries(cors)) {
|
|
258
|
+
responseHeaders.set(key, value)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// HTML response — strip scripts, inject inspector
|
|
262
|
+
if (isHtmlResponse(contentType)) {
|
|
263
|
+
const html = await targetRes.text()
|
|
264
|
+
const modified = injectIntoHtml(html, inspectorScript)
|
|
265
|
+
|
|
266
|
+
responseHeaders.set('Content-Type', 'text/html; charset=utf-8')
|
|
267
|
+
responseHeaders.delete('content-length')
|
|
268
|
+
responseHeaders.set(
|
|
269
|
+
'Cache-Control',
|
|
270
|
+
'no-cache, no-store, must-revalidate',
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return new Response(modified, {
|
|
274
|
+
status: targetRes.status,
|
|
275
|
+
headers: responseHeaders,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// CSS response — rewrite absolute target URLs
|
|
280
|
+
if (isCssResponse(contentType)) {
|
|
281
|
+
const css = await targetRes.text()
|
|
282
|
+
const modified = rewriteCssUrls(css, targetOrigin)
|
|
283
|
+
|
|
284
|
+
responseHeaders.delete('content-length')
|
|
285
|
+
responseHeaders.set('Cache-Control', 'public, max-age=3600')
|
|
286
|
+
|
|
287
|
+
return new Response(modified, {
|
|
288
|
+
status: targetRes.status,
|
|
289
|
+
headers: responseHeaders,
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Font response — long cache
|
|
294
|
+
if (
|
|
295
|
+
contentType &&
|
|
296
|
+
(contentType.includes('font') ||
|
|
297
|
+
pathname.match(/\.(woff2?|ttf|otf|eot)$/i))
|
|
298
|
+
) {
|
|
299
|
+
responseHeaders.set(
|
|
300
|
+
'Cache-Control',
|
|
301
|
+
'public, max-age=31536000, immutable',
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Image response — always revalidate so updated assets on the target
|
|
306
|
+
// are reflected immediately instead of being served from browser cache.
|
|
307
|
+
if (
|
|
308
|
+
(contentType && contentType.includes('image/')) ||
|
|
309
|
+
pathname.match(/\.(png|jpe?g|gif|svg|ico|webp|avif)(\?|$)/i)
|
|
310
|
+
) {
|
|
311
|
+
responseHeaders.set(
|
|
312
|
+
'Cache-Control',
|
|
313
|
+
'no-cache, no-store, must-revalidate',
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// All other responses — passthrough
|
|
318
|
+
return new Response(targetRes.body, {
|
|
319
|
+
status: targetRes.status,
|
|
320
|
+
headers: responseHeaders,
|
|
321
|
+
})
|
|
322
|
+
} catch (err) {
|
|
323
|
+
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
324
|
+
return Response.json(
|
|
325
|
+
{ error: `Failed to proxy request: ${message}` },
|
|
326
|
+
{ status: 502, headers: cors },
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pAInt Bridge Server
|
|
3
|
+
*
|
|
4
|
+
* A lightweight Bun HTTP server that runs on the user's machine.
|
|
5
|
+
* When pAInt is deployed on Vercel, it connects to this bridge
|
|
6
|
+
* to proxy localhost pages, scan project directories, and run Claude CLI.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* bun run src/bridge/server.ts
|
|
10
|
+
* # or
|
|
11
|
+
* bun run bridge
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { handleProxy } from './proxy-handler'
|
|
17
|
+
import { handleAPI } from './api-handlers'
|
|
18
|
+
|
|
19
|
+
const BRIDGE_PORT = Number(process.env.BRIDGE_PORT) || 4002
|
|
20
|
+
|
|
21
|
+
const ALLOWED_ORIGIN_PATTERNS = [
|
|
22
|
+
'https://dev-editor-flow.vercel.app',
|
|
23
|
+
/^http:\/\/localhost(:\d+)?$/,
|
|
24
|
+
/^http:\/\/127\.0\.0\.1(:\d+)?$/,
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
function isAllowedOrigin(origin: string): boolean {
|
|
28
|
+
for (const pattern of ALLOWED_ORIGIN_PATTERNS) {
|
|
29
|
+
if (typeof pattern === 'string') {
|
|
30
|
+
if (origin === pattern) return true
|
|
31
|
+
} else {
|
|
32
|
+
if (pattern.test(origin)) return true
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function corsHeaders(requestOrigin: string | null): Record<string, string> {
|
|
39
|
+
const origin =
|
|
40
|
+
requestOrigin && isAllowedOrigin(requestOrigin)
|
|
41
|
+
? requestOrigin
|
|
42
|
+
: 'https://dev-editor-flow.vercel.app'
|
|
43
|
+
return {
|
|
44
|
+
'Access-Control-Allow-Origin': origin,
|
|
45
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
46
|
+
'Access-Control-Allow-Headers': 'Content-Type, x-dev-editor-target, Accept',
|
|
47
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
48
|
+
'Access-Control-Max-Age': '86400',
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Cache the inspector script at startup
|
|
53
|
+
let inspectorScript: string | null = null
|
|
54
|
+
const inspectorPath = join(
|
|
55
|
+
import.meta.dir,
|
|
56
|
+
'../../public/dev-editor-inspector.js',
|
|
57
|
+
)
|
|
58
|
+
if (existsSync(inspectorPath)) {
|
|
59
|
+
inspectorScript = readFileSync(inspectorPath, 'utf-8')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
Bun.serve({
|
|
63
|
+
port: BRIDGE_PORT,
|
|
64
|
+
|
|
65
|
+
async fetch(req) {
|
|
66
|
+
const url = new URL(req.url)
|
|
67
|
+
const origin = req.headers.get('origin')
|
|
68
|
+
const cors = corsHeaders(origin)
|
|
69
|
+
|
|
70
|
+
// CORS preflight
|
|
71
|
+
if (req.method === 'OPTIONS') {
|
|
72
|
+
return new Response(null, { status: 204, headers: cors })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Health check (used for auto-discovery from Vercel editor)
|
|
76
|
+
if (url.pathname === '/health') {
|
|
77
|
+
return Response.json(
|
|
78
|
+
{ status: 'ok', version: '1.0.0', bridge: true },
|
|
79
|
+
{ headers: cors },
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Serve the inspector script
|
|
84
|
+
if (url.pathname === '/dev-editor-inspector.js') {
|
|
85
|
+
if (!inspectorScript) {
|
|
86
|
+
return new Response('Inspector script not found', {
|
|
87
|
+
status: 404,
|
|
88
|
+
headers: cors,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
return new Response(inspectorScript, {
|
|
92
|
+
headers: {
|
|
93
|
+
...cors,
|
|
94
|
+
'Content-Type': 'application/javascript',
|
|
95
|
+
'Cache-Control': 'public, max-age=3600',
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// API routes
|
|
101
|
+
if (url.pathname.startsWith('/api/')) {
|
|
102
|
+
return handleAPI(req, url, cors)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Everything else: proxy to target localhost
|
|
106
|
+
return handleProxy(req, url, cors, inspectorScript)
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
console.log(`\n pAInt Bridge running on http://localhost:${BRIDGE_PORT}`)
|
|
111
|
+
console.log(
|
|
112
|
+
` Connect from: https://dev-editor-flow.vercel.app?bridge=localhost:${BRIDGE_PORT}\n`,
|
|
113
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react'
|
|
4
|
+
import { useEditorStore } from '@/store'
|
|
5
|
+
import { usePostMessage } from '@/hooks/usePostMessage'
|
|
6
|
+
import {
|
|
7
|
+
BREAKPOINTS,
|
|
8
|
+
DEVICE_PRESETS,
|
|
9
|
+
BREAKPOINT_CATEGORY_MAP,
|
|
10
|
+
} from '@/lib/constants'
|
|
11
|
+
import type { Breakpoint } from '@/types/changelog'
|
|
12
|
+
|
|
13
|
+
export function BreakpointTabs() {
|
|
14
|
+
const activeBreakpoint = useEditorStore((s) => s.activeBreakpoint)
|
|
15
|
+
const previewWidth = useEditorStore((s) => s.previewWidth)
|
|
16
|
+
const setActiveBreakpoint = useEditorStore((s) => s.setActiveBreakpoint)
|
|
17
|
+
const setPreviewWidth = useEditorStore((s) => s.setPreviewWidth)
|
|
18
|
+
const { sendToInspector } = usePostMessage()
|
|
19
|
+
|
|
20
|
+
const breakpoints = Object.entries(BREAKPOINTS) as [
|
|
21
|
+
Breakpoint,
|
|
22
|
+
{ label: string; width: number },
|
|
23
|
+
][]
|
|
24
|
+
|
|
25
|
+
// Find matched device for the active breakpoint category
|
|
26
|
+
const activeCategory = BREAKPOINT_CATEGORY_MAP[activeBreakpoint]
|
|
27
|
+
const matchedDevice = DEVICE_PRESETS.find(
|
|
28
|
+
(d) => d.category === activeCategory && d.width === previewWidth,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
const handleBreakpointChange = useCallback(
|
|
32
|
+
(bp: Breakpoint) => {
|
|
33
|
+
setActiveBreakpoint(bp)
|
|
34
|
+
setPreviewWidth(BREAKPOINTS[bp].width)
|
|
35
|
+
sendToInspector({
|
|
36
|
+
type: 'SET_BREAKPOINT',
|
|
37
|
+
payload: { width: BREAKPOINTS[bp].width },
|
|
38
|
+
})
|
|
39
|
+
},
|
|
40
|
+
[setActiveBreakpoint, setPreviewWidth, sendToInspector],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="flex items-center gap-1">
|
|
45
|
+
{breakpoints.map(([key, { label, width }]) => (
|
|
46
|
+
<button
|
|
47
|
+
key={key}
|
|
48
|
+
onClick={() => handleBreakpointChange(key)}
|
|
49
|
+
className="px-2 py-0.5 text-[11px] rounded transition-colors"
|
|
50
|
+
style={{
|
|
51
|
+
background:
|
|
52
|
+
activeBreakpoint === key ? 'var(--accent-bg)' : 'transparent',
|
|
53
|
+
color:
|
|
54
|
+
activeBreakpoint === key ? 'var(--accent)' : 'var(--text-muted)',
|
|
55
|
+
}}
|
|
56
|
+
title={`${label} (${width}px)`}
|
|
57
|
+
>
|
|
58
|
+
{label}
|
|
59
|
+
</button>
|
|
60
|
+
))}
|
|
61
|
+
{/* Show matched device name + width for the active breakpoint */}
|
|
62
|
+
<span
|
|
63
|
+
className="ml-1 text-[10px] truncate max-w-[160px]"
|
|
64
|
+
style={{ color: 'var(--text-muted)' }}
|
|
65
|
+
>
|
|
66
|
+
{matchedDevice
|
|
67
|
+
? `${matchedDevice.name} · ${matchedDevice.width}px`
|
|
68
|
+
: `${previewWidth}px`}
|
|
69
|
+
</span>
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|