pmx-canvas 0.3.4 → 0.4.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/CHANGELOG.md +76 -0
- package/Readme.md +16 -3
- package/dist/canvas/index.js +80 -80
- package/dist/types/client/index.d.ts +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +10 -0
- package/dist/types/client/nodes/iframe-document-url.d.ts +1 -0
- package/dist/types/client/nodes/use-surface-frame.d.ts +13 -0
- package/dist/types/client/state/canvas-store.d.ts +1 -0
- package/dist/types/client/state/iframe-mode.d.ts +25 -0
- package/dist/types/client/state/node-factory.d.ts +29 -0
- package/dist/types/client/uuid-polyfill.d.ts +0 -0
- package/dist/types/server/canvas-db.d.ts +0 -1
- package/dist/types/server/canvas-state.d.ts +14 -15
- package/dist/types/server/operations/composites.d.ts +6 -21
- package/dist/types/server/server.d.ts +1 -0
- package/docs/RELEASE.md +10 -4
- package/docs/cli.md +5 -0
- package/docs/environment.md +2 -2
- package/docs/http-api.md +47 -1
- package/docs/mcp.md +21 -30
- package/docs/plans/plan-009-tech-debt-backlog.md +22 -13
- package/docs/screenshot.png +0 -0
- package/docs/tech-debt-assessment-2026-06.md +5 -5
- package/package.json +7 -7
- package/skills/pmx-canvas/SKILL.md +36 -10
- package/skills/pmx-canvas/evals/evals.json +2 -2
- package/skills/pmx-canvas/references/full-reference.md +17 -15
- package/skills/pmx-canvas-testing/SKILL.md +3 -0
- package/src/cli/commands/history.ts +1 -1
- package/src/cli/commands/nodes.ts +31 -1
- package/src/cli/index.ts +8 -2
- package/src/cli/shared.ts +1 -0
- package/src/client/index.tsx +7 -0
- package/src/client/nodes/ExtAppFrame.tsx +50 -2
- package/src/client/nodes/HtmlNode.tsx +5 -1
- package/src/client/nodes/McpAppNode.tsx +5 -1
- package/src/client/nodes/iframe-document-url.ts +62 -9
- package/src/client/nodes/use-surface-frame.ts +53 -0
- package/src/client/state/canvas-store.ts +5 -0
- package/src/client/state/iframe-mode.ts +103 -0
- package/src/client/state/node-factory.ts +72 -0
- package/src/client/state/sse-bridge.ts +212 -86
- package/src/client/uuid-polyfill.ts +14 -0
- package/src/server/canvas-db.ts +0 -8
- package/src/server/canvas-schema.ts +2 -8
- package/src/server/canvas-state.ts +51 -151
- package/src/server/index.ts +3 -9
- package/src/server/operations/composites.ts +24 -21
- package/src/server/operations/ops/snapshots.ts +6 -7
- package/src/server/operations/registry.ts +4 -1
- package/src/server/server.ts +194 -75
package/src/server/server.ts
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* - GET /api/canvas/json-render/view?nodeId=... -> local json-render viewer
|
|
25
25
|
* - POST /api/canvas/web-artifact -> build bundled HTML artifact + optional canvas node
|
|
26
26
|
* - GET /api/workbench/events -> SSE event stream
|
|
27
|
+
* - GET /api/workbench/poll -> proxy-safe polling transport (same events, JSON)
|
|
27
28
|
* - GET /api/workbench/state -> workbench state snapshot
|
|
28
29
|
* - POST /api/workbench/intent -> workbench intents
|
|
29
30
|
* - GET /api/workbench/webview -> Bun.WebView automation status
|
|
@@ -710,7 +711,24 @@ function toSseFrame(event: string, payload: PrimaryWorkbenchEventPayload): Uint8
|
|
|
710
711
|
return textEncoder.encode(`${lines.join('\n')}\n`);
|
|
711
712
|
}
|
|
712
713
|
|
|
714
|
+
// ── Workbench event ring (proxy-safe polling transport) ─────────
|
|
715
|
+
// Buffering proxies (e.g. the Amp orb portal) hold a long-lived SSE response
|
|
716
|
+
// open and never flush it to the browser, so an EventSource client behind one
|
|
717
|
+
// receives nothing and the canvas never boots. Every broadcast event is also
|
|
718
|
+
// recorded here with a monotonic seq; GET /api/workbench/poll replays them as
|
|
719
|
+
// a normal, immediately-closed JSON response that any proxy delivers. Bounded:
|
|
720
|
+
// a client whose `since` fell off the ring gets a fresh connect snapshot,
|
|
721
|
+
// which fully rebuilds client state (same events an SSE connect sends).
|
|
722
|
+
const WORKBENCH_EVENT_RING_MAX = 500;
|
|
723
|
+
let workbenchEventSeq = 0;
|
|
724
|
+
const workbenchEventRing: Array<{ seq: number; event: string; payload: PrimaryWorkbenchEventPayload }> = [];
|
|
725
|
+
|
|
713
726
|
function broadcastWorkbenchEvent(event: string, payload: PrimaryWorkbenchEventPayload): void {
|
|
727
|
+
workbenchEventSeq += 1;
|
|
728
|
+
workbenchEventRing.push({ seq: workbenchEventSeq, event, payload });
|
|
729
|
+
if (workbenchEventRing.length > WORKBENCH_EVENT_RING_MAX) {
|
|
730
|
+
workbenchEventRing.splice(0, workbenchEventRing.length - WORKBENCH_EVENT_RING_MAX);
|
|
731
|
+
}
|
|
714
732
|
const frame = toSseFrame(event, payload);
|
|
715
733
|
for (const [subscriberId, controller] of workbenchSubscribers.entries()) {
|
|
716
734
|
try {
|
|
@@ -1121,6 +1139,20 @@ function handleFrameDocument(pathname: string): Response {
|
|
|
1121
1139
|
});
|
|
1122
1140
|
}
|
|
1123
1141
|
|
|
1142
|
+
// Tiny always-200 document the client loads in a hidden iframe at boot to learn
|
|
1143
|
+
// whether this embedding context loads iframes from src URLs at all — nested
|
|
1144
|
+
// iframe hosts like the Amp orb portal block them, and the client then falls
|
|
1145
|
+
// back to fetch() + srcdoc surfaces (see src/client/state/iframe-mode.ts).
|
|
1146
|
+
function handleIframeProbe(): Response {
|
|
1147
|
+
return new Response('<!doctype html><title>pmx-canvas iframe probe</title>', {
|
|
1148
|
+
headers: {
|
|
1149
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1150
|
+
'Cache-Control': 'no-store',
|
|
1151
|
+
'Content-Security-Policy': 'sandbox allow-scripts',
|
|
1152
|
+
},
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1124
1156
|
// ── Ext-app recovery diagnostics (0.3.2 report Finding N) ──────
|
|
1125
1157
|
//
|
|
1126
1158
|
// The WebKit black-tile recovery trail is otherwise trapped inside host panels
|
|
@@ -1372,14 +1404,19 @@ function responseJson(data: unknown, status = 200): Response {
|
|
|
1372
1404
|
});
|
|
1373
1405
|
}
|
|
1374
1406
|
|
|
1407
|
+
// v0.4.0 (M4): every hand-written error response is a JSON envelope
|
|
1408
|
+
// ({ ok: false, error }, same status codes) matching the registry dispatcher —
|
|
1409
|
+
// consumers no longer need to branch on text/plain vs JSON error bodies.
|
|
1375
1410
|
function responseText(text: string, status = 400): Response {
|
|
1376
|
-
return
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1411
|
+
return Response.json(
|
|
1412
|
+
{ ok: false, error: text },
|
|
1413
|
+
{
|
|
1414
|
+
status,
|
|
1415
|
+
headers: {
|
|
1416
|
+
'Cache-Control': 'no-store',
|
|
1417
|
+
},
|
|
1381
1418
|
},
|
|
1382
|
-
|
|
1419
|
+
);
|
|
1383
1420
|
}
|
|
1384
1421
|
|
|
1385
1422
|
function handleArtifactView(url: URL): Response {
|
|
@@ -1692,15 +1729,134 @@ async function handleWorkbenchIntent(req: Request): Promise<Response> {
|
|
|
1692
1729
|
return responseJson({ ok: true, intent });
|
|
1693
1730
|
}
|
|
1694
1731
|
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1732
|
+
/**
|
|
1733
|
+
* The connect snapshot: the ordered event list that fully rebuilds a client's
|
|
1734
|
+
* state. Single source for BOTH transports — the SSE stream enqueues these
|
|
1735
|
+
* frames on connect, and /api/workbench/poll returns them as JSON when a
|
|
1736
|
+
* poller has no usable `since` (fresh client, server restart, or ring
|
|
1737
|
+
* eviction). Adding a new connect-time event here reaches both paths.
|
|
1738
|
+
*/
|
|
1739
|
+
function buildWorkbenchConnectSnapshot(
|
|
1740
|
+
requestedSessionId: string,
|
|
1741
|
+
): Array<{ event: string; payload: Record<string, unknown> }> {
|
|
1698
1742
|
const continuity =
|
|
1699
1743
|
requestedSessionId.length === 0
|
|
1700
1744
|
? 'fresh'
|
|
1701
1745
|
: requestedSessionId === primaryWorkbenchSessionId
|
|
1702
1746
|
? 'resumed'
|
|
1703
1747
|
: 'rotated';
|
|
1748
|
+
const events: Array<{ event: string; payload: Record<string, unknown> }> = [];
|
|
1749
|
+
events.push({
|
|
1750
|
+
event: 'connected',
|
|
1751
|
+
payload: {
|
|
1752
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1753
|
+
requestedSessionId: requestedSessionId || null,
|
|
1754
|
+
continuity,
|
|
1755
|
+
path: primaryWorkbenchPath,
|
|
1756
|
+
theme: canvasState.theme,
|
|
1757
|
+
timestamp: new Date().toISOString(),
|
|
1758
|
+
},
|
|
1759
|
+
});
|
|
1760
|
+
if (primaryWorkbenchPath) {
|
|
1761
|
+
events.push({
|
|
1762
|
+
event: 'workbench-open',
|
|
1763
|
+
payload: {
|
|
1764
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1765
|
+
path: primaryWorkbenchPath,
|
|
1766
|
+
title: basename(primaryWorkbenchPath),
|
|
1767
|
+
source: 'snapshot',
|
|
1768
|
+
updatedAt: new Date().toISOString(),
|
|
1769
|
+
},
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
if (lastWorkbenchContextCardsEnvelope) {
|
|
1773
|
+
events.push({
|
|
1774
|
+
event: 'context-cards',
|
|
1775
|
+
payload: {
|
|
1776
|
+
...lastWorkbenchContextCardsEnvelope,
|
|
1777
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1778
|
+
timestamp: new Date().toISOString(),
|
|
1779
|
+
},
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
events.push({
|
|
1783
|
+
event: 'mcp-app-host-snapshot',
|
|
1784
|
+
payload: {
|
|
1785
|
+
reason: 'connect-snapshot',
|
|
1786
|
+
...getMcpAppHostSnapshot(),
|
|
1787
|
+
timestamp: new Date().toISOString(),
|
|
1788
|
+
},
|
|
1789
|
+
});
|
|
1790
|
+
events.push({
|
|
1791
|
+
event: 'canvas-layout-update',
|
|
1792
|
+
payload: {
|
|
1793
|
+
layout: canvasState.getLayout(),
|
|
1794
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1795
|
+
timestamp: new Date().toISOString(),
|
|
1796
|
+
},
|
|
1797
|
+
});
|
|
1798
|
+
events.push({
|
|
1799
|
+
event: 'context-pins-changed',
|
|
1800
|
+
payload: {
|
|
1801
|
+
count: canvasState.contextPinnedNodeIds.size,
|
|
1802
|
+
nodeIds: Array.from(canvasState.contextPinnedNodeIds),
|
|
1803
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1804
|
+
timestamp: new Date().toISOString(),
|
|
1805
|
+
},
|
|
1806
|
+
});
|
|
1807
|
+
for (const intent of intentRegistry.list()) {
|
|
1808
|
+
events.push({
|
|
1809
|
+
event: 'ax-intent',
|
|
1810
|
+
payload: {
|
|
1811
|
+
intent,
|
|
1812
|
+
reason: 'connect-snapshot',
|
|
1813
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1814
|
+
timestamp: new Date().toISOString(),
|
|
1815
|
+
},
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
return events;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/**
|
|
1822
|
+
* Proxy-safe transport (Amp orb portals and any proxy that buffers streaming
|
|
1823
|
+
* responses): returns the buffered events after `since` — or the full connect
|
|
1824
|
+
* snapshot when `since` is absent, from a previous server run, or already
|
|
1825
|
+
* evicted from the ring — as a normal JSON response that closes immediately.
|
|
1826
|
+
* The client polls every couple of seconds; each response flushes through any
|
|
1827
|
+
* proxy. `seq` is the cursor for the next poll.
|
|
1828
|
+
*/
|
|
1829
|
+
function handleWorkbenchPoll(url: URL): Response {
|
|
1830
|
+
rotatePrimaryWorkbenchSessionIfNeeded();
|
|
1831
|
+
const requestedSessionId = url.searchParams.get('session')?.trim() ?? '';
|
|
1832
|
+
const sinceRaw = url.searchParams.get('since');
|
|
1833
|
+
const since = sinceRaw === null ? Number.NaN : Number(sinceRaw);
|
|
1834
|
+
const ringHeadSeq =
|
|
1835
|
+
workbenchEventRing.length > 0 ? (workbenchEventRing[0] as { seq: number }).seq : workbenchEventSeq + 1;
|
|
1836
|
+
const usableSince = Number.isInteger(since) && since >= 0 && since <= workbenchEventSeq && since + 1 >= ringHeadSeq;
|
|
1837
|
+
if (!usableSince) {
|
|
1838
|
+
return responseJson({
|
|
1839
|
+
ok: true,
|
|
1840
|
+
transport: 'poll',
|
|
1841
|
+
snapshot: true,
|
|
1842
|
+
seq: workbenchEventSeq,
|
|
1843
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1844
|
+
events: buildWorkbenchConnectSnapshot(requestedSessionId),
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
return responseJson({
|
|
1848
|
+
ok: true,
|
|
1849
|
+
transport: 'poll',
|
|
1850
|
+
snapshot: false,
|
|
1851
|
+
seq: workbenchEventSeq,
|
|
1852
|
+
sessionId: primaryWorkbenchSessionId,
|
|
1853
|
+
events: workbenchEventRing.filter((entry) => entry.seq > since),
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function handleWorkbenchEvents(req: Request): Response {
|
|
1858
|
+
const reqUrl = new URL(req.url);
|
|
1859
|
+
const requestedSessionId = reqUrl.searchParams.get('session')?.trim() ?? '';
|
|
1704
1860
|
const subscriberId = nextWorkbenchSubscriberId++;
|
|
1705
1861
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
1706
1862
|
|
|
@@ -1708,68 +1864,8 @@ function handleWorkbenchEvents(req: Request): Response {
|
|
|
1708
1864
|
start(controller) {
|
|
1709
1865
|
workbenchSubscribers.set(subscriberId, controller);
|
|
1710
1866
|
syncCanvasBrowserOpenedFromSubscribers();
|
|
1711
|
-
|
|
1712
|
-
toSseFrame(
|
|
1713
|
-
sessionId: primaryWorkbenchSessionId,
|
|
1714
|
-
requestedSessionId: requestedSessionId || null,
|
|
1715
|
-
continuity,
|
|
1716
|
-
path: primaryWorkbenchPath,
|
|
1717
|
-
theme: canvasState.theme,
|
|
1718
|
-
timestamp: new Date().toISOString(),
|
|
1719
|
-
}),
|
|
1720
|
-
);
|
|
1721
|
-
if (primaryWorkbenchPath) {
|
|
1722
|
-
controller.enqueue(
|
|
1723
|
-
toSseFrame('workbench-open', {
|
|
1724
|
-
sessionId: primaryWorkbenchSessionId,
|
|
1725
|
-
path: primaryWorkbenchPath,
|
|
1726
|
-
title: basename(primaryWorkbenchPath),
|
|
1727
|
-
source: 'snapshot',
|
|
1728
|
-
updatedAt: new Date().toISOString(),
|
|
1729
|
-
}),
|
|
1730
|
-
);
|
|
1731
|
-
}
|
|
1732
|
-
if (lastWorkbenchContextCardsEnvelope) {
|
|
1733
|
-
controller.enqueue(
|
|
1734
|
-
toSseFrame('context-cards', {
|
|
1735
|
-
...lastWorkbenchContextCardsEnvelope,
|
|
1736
|
-
sessionId: primaryWorkbenchSessionId,
|
|
1737
|
-
timestamp: new Date().toISOString(),
|
|
1738
|
-
}),
|
|
1739
|
-
);
|
|
1740
|
-
}
|
|
1741
|
-
controller.enqueue(
|
|
1742
|
-
toSseFrame('mcp-app-host-snapshot', {
|
|
1743
|
-
reason: 'connect-snapshot',
|
|
1744
|
-
...getMcpAppHostSnapshot(),
|
|
1745
|
-
timestamp: new Date().toISOString(),
|
|
1746
|
-
}),
|
|
1747
|
-
);
|
|
1748
|
-
const layout = canvasState.getLayout();
|
|
1749
|
-
controller.enqueue(
|
|
1750
|
-
toSseFrame('canvas-layout-update', {
|
|
1751
|
-
layout,
|
|
1752
|
-
sessionId: primaryWorkbenchSessionId,
|
|
1753
|
-
timestamp: new Date().toISOString(),
|
|
1754
|
-
}),
|
|
1755
|
-
);
|
|
1756
|
-
controller.enqueue(
|
|
1757
|
-
toSseFrame('context-pins-changed', {
|
|
1758
|
-
count: canvasState.contextPinnedNodeIds.size,
|
|
1759
|
-
nodeIds: Array.from(canvasState.contextPinnedNodeIds),
|
|
1760
|
-
sessionId: primaryWorkbenchSessionId,
|
|
1761
|
-
timestamp: new Date().toISOString(),
|
|
1762
|
-
}),
|
|
1763
|
-
);
|
|
1764
|
-
for (const intent of intentRegistry.list()) {
|
|
1765
|
-
controller.enqueue(
|
|
1766
|
-
toSseFrame('ax-intent', {
|
|
1767
|
-
intent,
|
|
1768
|
-
reason: 'connect-snapshot',
|
|
1769
|
-
sessionId: primaryWorkbenchSessionId,
|
|
1770
|
-
timestamp: new Date().toISOString(),
|
|
1771
|
-
}),
|
|
1772
|
-
);
|
|
1867
|
+
for (const { event, payload } of buildWorkbenchConnectSnapshot(requestedSessionId)) {
|
|
1868
|
+
controller.enqueue(toSseFrame(event, payload));
|
|
1773
1869
|
}
|
|
1774
1870
|
pingTimer = setInterval(() => {
|
|
1775
1871
|
try {
|
|
@@ -2855,7 +2951,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
2855
2951
|
|
|
2856
2952
|
rotatePrimaryWorkbenchSessionIfNeeded();
|
|
2857
2953
|
|
|
2858
|
-
|
|
2954
|
+
// M11: PMX_WEB_CANVAS_PORT (server-specific) wins, then PMX_CANVAS_PORT — so
|
|
2955
|
+
// setting only the client var no longer splits the client and server ports.
|
|
2956
|
+
const preferredPort =
|
|
2957
|
+
options.port ?? Number(process.env.PMX_WEB_CANVAS_PORT ?? process.env.PMX_CANVAS_PORT ?? DEFAULT_PORT);
|
|
2859
2958
|
const portCandidates =
|
|
2860
2959
|
options.port === 0
|
|
2861
2960
|
? [0]
|
|
@@ -2884,8 +2983,15 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
2884
2983
|
if (url.pathname === '/health') {
|
|
2885
2984
|
// `pid` lets `serve status` report the ACTUAL serving process even
|
|
2886
2985
|
// when the pid file is stale (e.g. an adapter respawned the server
|
|
2887
|
-
// outside `serve --daemon` — 0.3.2 report Finding P).
|
|
2888
|
-
|
|
2986
|
+
// outside `serve --daemon` — 0.3.2 report Finding P). `persistence`
|
|
2987
|
+
// surfaces silent save failures (M4): the canvas keeps serving from
|
|
2988
|
+
// memory, but ok:false means durability is degraded.
|
|
2989
|
+
return responseJson({
|
|
2990
|
+
ok: true,
|
|
2991
|
+
workspace: activeWorkspaceRoot,
|
|
2992
|
+
pid: process.pid,
|
|
2993
|
+
persistence: canvasState.persistenceHealth,
|
|
2994
|
+
});
|
|
2889
2995
|
}
|
|
2890
2996
|
|
|
2891
2997
|
if (url.pathname === '/favicon.ico' || url.pathname === '/favicon.svg') {
|
|
@@ -2919,10 +3025,19 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
2919
3025
|
return handleCreateFrameDocument(req);
|
|
2920
3026
|
}
|
|
2921
3027
|
|
|
2922
|
-
|
|
3028
|
+
// HEAD lets clients revalidate a minted frame URL after reconnect
|
|
3029
|
+
// (Finding S) without downloading the whole document.
|
|
3030
|
+
if (
|
|
3031
|
+
url.pathname.startsWith('/api/canvas/frame-documents/') &&
|
|
3032
|
+
(req.method === 'GET' || req.method === 'HEAD')
|
|
3033
|
+
) {
|
|
2923
3034
|
return handleFrameDocument(url.pathname);
|
|
2924
3035
|
}
|
|
2925
3036
|
|
|
3037
|
+
if (url.pathname === '/api/canvas/iframe-probe' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
3038
|
+
return handleIframeProbe();
|
|
3039
|
+
}
|
|
3040
|
+
|
|
2926
3041
|
if (url.pathname.startsWith('/api/canvas/surface/') && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
2927
3042
|
return handleNodeSurface(url.pathname, url);
|
|
2928
3043
|
}
|
|
@@ -2952,6 +3067,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
2952
3067
|
return handleWorkbenchEvents(req);
|
|
2953
3068
|
}
|
|
2954
3069
|
|
|
3070
|
+
if (url.pathname === '/api/workbench/poll' && req.method === 'GET') {
|
|
3071
|
+
return handleWorkbenchPoll(url);
|
|
3072
|
+
}
|
|
3073
|
+
|
|
2955
3074
|
if (url.pathname === '/api/workbench/intent' && req.method === 'POST') {
|
|
2956
3075
|
return handleWorkbenchIntent(req);
|
|
2957
3076
|
}
|