pywire 0.1.1__py3-none-any.whl → 0.1.3__py3-none-any.whl
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.
- pywire/__init__.py +2 -0
- pywire/cli/__init__.py +1 -0
- pywire/cli/generators.py +48 -0
- pywire/cli/main.py +309 -0
- pywire/cli/tui.py +563 -0
- pywire/cli/validate.py +26 -0
- pywire/client/.prettierignore +8 -0
- pywire/client/.prettierrc +7 -0
- pywire/client/build.mjs +73 -0
- pywire/client/eslint.config.js +46 -0
- pywire/client/package.json +39 -0
- pywire/client/pnpm-lock.yaml +2971 -0
- pywire/client/src/core/app.ts +263 -0
- pywire/client/src/core/dom-updater.test.ts +78 -0
- pywire/client/src/core/dom-updater.ts +321 -0
- pywire/client/src/core/index.ts +5 -0
- pywire/client/src/core/transport-manager.test.ts +179 -0
- pywire/client/src/core/transport-manager.ts +159 -0
- pywire/client/src/core/transports/base.ts +122 -0
- pywire/client/src/core/transports/http.ts +142 -0
- pywire/client/src/core/transports/index.ts +13 -0
- pywire/client/src/core/transports/websocket.ts +97 -0
- pywire/client/src/core/transports/webtransport.ts +149 -0
- pywire/client/src/dev/dev-app.ts +93 -0
- pywire/client/src/dev/error-trace.test.ts +97 -0
- pywire/client/src/dev/error-trace.ts +76 -0
- pywire/client/src/dev/index.ts +4 -0
- pywire/client/src/dev/status-overlay.ts +63 -0
- pywire/client/src/events/handler.test.ts +318 -0
- pywire/client/src/events/handler.ts +454 -0
- pywire/client/src/pywire.core.ts +22 -0
- pywire/client/src/pywire.dev.ts +27 -0
- pywire/client/tsconfig.json +17 -0
- pywire/client/vitest.config.ts +15 -0
- pywire/compiler/__init__.py +6 -0
- pywire/compiler/ast_nodes.py +304 -0
- pywire/compiler/attributes/__init__.py +6 -0
- pywire/compiler/attributes/base.py +24 -0
- pywire/compiler/attributes/conditional.py +37 -0
- pywire/compiler/attributes/events.py +55 -0
- pywire/compiler/attributes/form.py +37 -0
- pywire/compiler/attributes/loop.py +75 -0
- pywire/compiler/attributes/reactive.py +34 -0
- pywire/compiler/build.py +28 -0
- pywire/compiler/build_artifacts.py +342 -0
- pywire/compiler/codegen/__init__.py +5 -0
- pywire/compiler/codegen/attributes/__init__.py +6 -0
- pywire/compiler/codegen/attributes/base.py +19 -0
- pywire/compiler/codegen/attributes/events.py +35 -0
- pywire/compiler/codegen/directives/__init__.py +6 -0
- pywire/compiler/codegen/directives/base.py +16 -0
- pywire/compiler/codegen/directives/path.py +53 -0
- pywire/compiler/codegen/generator.py +2341 -0
- pywire/compiler/codegen/template.py +2178 -0
- pywire/compiler/directives/__init__.py +7 -0
- pywire/compiler/directives/base.py +20 -0
- pywire/compiler/directives/component.py +33 -0
- pywire/compiler/directives/context.py +93 -0
- pywire/compiler/directives/layout.py +49 -0
- pywire/compiler/directives/no_spa.py +24 -0
- pywire/compiler/directives/path.py +71 -0
- pywire/compiler/directives/props.py +88 -0
- pywire/compiler/exceptions.py +19 -0
- pywire/compiler/interpolation/__init__.py +6 -0
- pywire/compiler/interpolation/base.py +28 -0
- pywire/compiler/interpolation/jinja.py +272 -0
- pywire/compiler/parser.py +750 -0
- pywire/compiler/paths.py +29 -0
- pywire/compiler/preprocessor.py +43 -0
- pywire/core/wire.py +119 -0
- pywire/py.typed +0 -0
- pywire/runtime/__init__.py +7 -0
- pywire/runtime/aioquic_server.py +194 -0
- pywire/runtime/app.py +901 -0
- pywire/runtime/compile_error_page.py +195 -0
- pywire/runtime/debug.py +203 -0
- pywire/runtime/dev_server.py +433 -0
- pywire/runtime/dev_server.py.broken +268 -0
- pywire/runtime/error_page.py +64 -0
- pywire/runtime/error_renderer.py +23 -0
- pywire/runtime/escape.py +23 -0
- pywire/runtime/files.py +40 -0
- pywire/runtime/helpers.py +97 -0
- pywire/runtime/http_transport.py +253 -0
- pywire/runtime/loader.py +272 -0
- pywire/runtime/logging.py +72 -0
- pywire/runtime/page.py +384 -0
- pywire/runtime/pydantic_integration.py +52 -0
- pywire/runtime/router.py +229 -0
- pywire/runtime/server.py +25 -0
- pywire/runtime/style_collector.py +31 -0
- pywire/runtime/upload_manager.py +76 -0
- pywire/runtime/validation.py +449 -0
- pywire/runtime/websocket.py +665 -0
- pywire/runtime/webtransport_handler.py +195 -0
- pywire/static/pywire.core.min.js +3 -0
- pywire/static/pywire.dev.min.js +20 -0
- {pywire-0.1.1.dist-info → pywire-0.1.3.dist-info}/METADATA +1 -1
- pywire-0.1.3.dist-info/RECORD +106 -0
- pywire-0.1.1.dist-info/RECORD +0 -9
- {pywire-0.1.1.dist-info → pywire-0.1.3.dist-info}/WHEEL +0 -0
- {pywire-0.1.1.dist-info → pywire-0.1.3.dist-info}/entry_points.txt +0 -0
- {pywire-0.1.1.dist-info → pywire-0.1.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { TransportManager } from './transport-manager'
|
|
3
|
+
import { WebTransportTransport } from './transports/webtransport'
|
|
4
|
+
import { WebSocketTransport } from './transports/websocket'
|
|
5
|
+
import { HTTPTransport } from './transports/http'
|
|
6
|
+
|
|
7
|
+
// Create mock classes that can be used in the tests
|
|
8
|
+
class MockWebTransport {
|
|
9
|
+
static isSupported = vi.fn(() => true)
|
|
10
|
+
connect = vi.fn().mockResolvedValue(undefined)
|
|
11
|
+
onMessage = vi.fn()
|
|
12
|
+
onStatusChange = vi.fn()
|
|
13
|
+
isConnected = vi.fn(() => false)
|
|
14
|
+
send = vi.fn()
|
|
15
|
+
disconnect = vi.fn()
|
|
16
|
+
name = 'WebTransportTransport'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class MockWebSocket {
|
|
20
|
+
connect = vi.fn().mockResolvedValue(undefined)
|
|
21
|
+
onMessage = vi.fn()
|
|
22
|
+
onStatusChange = vi.fn()
|
|
23
|
+
isConnected = vi.fn(() => false)
|
|
24
|
+
send = vi.fn()
|
|
25
|
+
disconnect = vi.fn()
|
|
26
|
+
name = 'WebSocketTransport'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
class MockHTTP {
|
|
30
|
+
connect = vi.fn().mockResolvedValue(undefined)
|
|
31
|
+
onMessage = vi.fn()
|
|
32
|
+
onStatusChange = vi.fn()
|
|
33
|
+
isConnected = vi.fn(() => false)
|
|
34
|
+
send = vi.fn()
|
|
35
|
+
disconnect = vi.fn()
|
|
36
|
+
name = 'HTTPTransport'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
vi.mock('./transports/webtransport', () => ({
|
|
40
|
+
WebTransportTransport: vi.fn(),
|
|
41
|
+
}))
|
|
42
|
+
|
|
43
|
+
vi.mock('./transports/websocket', () => ({
|
|
44
|
+
WebSocketTransport: vi.fn(),
|
|
45
|
+
}))
|
|
46
|
+
|
|
47
|
+
vi.mock('./transports/http', () => ({
|
|
48
|
+
HTTPTransport: vi.fn(),
|
|
49
|
+
}))
|
|
50
|
+
|
|
51
|
+
type MockedTransportCtor = {
|
|
52
|
+
mockImplementation: (impl: new (...args: unknown[]) => unknown) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type MockedWebTransportCtor = MockedTransportCtor & {
|
|
56
|
+
isSupported: ReturnType<typeof vi.fn>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const WebTransportMock = WebTransportTransport as unknown as MockedWebTransportCtor
|
|
60
|
+
const WebSocketMock = WebSocketTransport as unknown as MockedTransportCtor
|
|
61
|
+
const HTTPMock = HTTPTransport as unknown as MockedTransportCtor
|
|
62
|
+
|
|
63
|
+
// Set static properties on the mocked constructors
|
|
64
|
+
WebTransportMock.isSupported = MockWebTransport.isSupported
|
|
65
|
+
Object.defineProperty(WebTransportTransport, 'name', { value: 'WebTransportTransport' })
|
|
66
|
+
Object.defineProperty(WebSocketTransport, 'name', { value: 'WebSocketTransport' })
|
|
67
|
+
Object.defineProperty(HTTPTransport, 'name', { value: 'HTTPTransport' })
|
|
68
|
+
|
|
69
|
+
describe('TransportManager', () => {
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
vi.clearAllMocks()
|
|
72
|
+
|
|
73
|
+
// Default mock implementations (successful connect)
|
|
74
|
+
WebTransportMock.mockImplementation(MockWebTransport)
|
|
75
|
+
WebSocketMock.mockImplementation(MockWebSocket)
|
|
76
|
+
HTTPMock.mockImplementation(MockHTTP)
|
|
77
|
+
|
|
78
|
+
vi.stubGlobal('location', { protocol: 'https:' })
|
|
79
|
+
vi.stubGlobal('WebSocket', vi.fn())
|
|
80
|
+
WebTransportMock.isSupported.mockReturnValue(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('should try WebTransport first if supported and on HTTPS', async () => {
|
|
84
|
+
const manager = new TransportManager()
|
|
85
|
+
await manager.connect()
|
|
86
|
+
|
|
87
|
+
expect(WebTransportTransport).toHaveBeenCalled()
|
|
88
|
+
expect(manager.getActiveTransport()).toBe('WebTransportTransport')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should fallback to WebSocket if WebTransport fails', async () => {
|
|
92
|
+
WebTransportMock.mockImplementation(
|
|
93
|
+
class extends MockWebTransport {
|
|
94
|
+
connect = vi.fn().mockRejectedValue(new Error('WT failed'))
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const manager = new TransportManager()
|
|
99
|
+
await manager.connect()
|
|
100
|
+
|
|
101
|
+
expect(WebTransportTransport).toHaveBeenCalled()
|
|
102
|
+
expect(WebSocketTransport).toHaveBeenCalled()
|
|
103
|
+
expect(manager.getActiveTransport()).toBe('WebSocketTransport')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should fallback to HTTP if WebSocket fails', async () => {
|
|
107
|
+
WebTransportMock.mockImplementation(
|
|
108
|
+
class extends MockWebTransport {
|
|
109
|
+
connect = vi.fn().mockRejectedValue(new Error('WT failed'))
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
WebSocketMock.mockImplementation(
|
|
113
|
+
class extends MockWebSocket {
|
|
114
|
+
connect = vi.fn().mockRejectedValue(new Error('WS failed'))
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const manager = new TransportManager()
|
|
119
|
+
await manager.connect()
|
|
120
|
+
|
|
121
|
+
expect(HTTPTransport).toHaveBeenCalled()
|
|
122
|
+
expect(manager.getActiveTransport()).toBe('HTTPTransport')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should skip WebTransport if not on HTTPS', async () => {
|
|
126
|
+
vi.stubGlobal('location', { protocol: 'http:' })
|
|
127
|
+
const manager = new TransportManager()
|
|
128
|
+
await manager.connect()
|
|
129
|
+
|
|
130
|
+
expect(WebTransportTransport).not.toHaveBeenCalled()
|
|
131
|
+
expect(WebSocketTransport).toHaveBeenCalled()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should respect config to disable transports', async () => {
|
|
135
|
+
const manager = new TransportManager({ enableWebTransport: false })
|
|
136
|
+
await manager.connect()
|
|
137
|
+
|
|
138
|
+
expect(WebTransportTransport).not.toHaveBeenCalled()
|
|
139
|
+
expect(WebSocketTransport).toHaveBeenCalled()
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should throw if all transports fail', async () => {
|
|
143
|
+
WebTransportMock.mockImplementation(
|
|
144
|
+
class extends MockWebTransport {
|
|
145
|
+
connect = vi.fn().mockRejectedValue(new Error('WT failed'))
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
WebSocketMock.mockImplementation(
|
|
149
|
+
class extends MockWebSocket {
|
|
150
|
+
connect = vi.fn().mockRejectedValue(new Error('WS failed'))
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
HTTPMock.mockImplementation(
|
|
154
|
+
class extends MockHTTP {
|
|
155
|
+
connect = vi.fn().mockRejectedValue(new Error('HTTP failed'))
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const manager = new TransportManager()
|
|
160
|
+
await expect(manager.connect()).rejects.toThrow('PyWire: All transports failed')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should forward messages to registered handlers', async () => {
|
|
164
|
+
const onMessageSpy = vi.fn()
|
|
165
|
+
WebTransportMock.mockImplementation(
|
|
166
|
+
class extends MockWebTransport {
|
|
167
|
+
onMessage = onMessageSpy
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const manager = new TransportManager()
|
|
172
|
+
const handler = vi.fn()
|
|
173
|
+
manager.onMessage(handler)
|
|
174
|
+
|
|
175
|
+
await manager.connect()
|
|
176
|
+
|
|
177
|
+
expect(onMessageSpy).toHaveBeenCalledWith(handler)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Transport, ServerMessage } from './transports'
|
|
2
|
+
import { WebTransportTransport } from './transports/webtransport'
|
|
3
|
+
import { WebSocketTransport } from './transports/websocket'
|
|
4
|
+
import { HTTPTransport } from './transports/http'
|
|
5
|
+
|
|
6
|
+
export interface TransportConfig {
|
|
7
|
+
/** Enable WebTransport (requires HTTPS and HTTP/3 server) */
|
|
8
|
+
enableWebTransport?: boolean
|
|
9
|
+
/** Enable WebSocket */
|
|
10
|
+
enableWebSocket?: boolean
|
|
11
|
+
/** Enable HTTP polling fallback */
|
|
12
|
+
enableHTTP?: boolean
|
|
13
|
+
/** Custom WebTransport URL */
|
|
14
|
+
webTransportUrl?: string
|
|
15
|
+
/** Custom WebSocket URL */
|
|
16
|
+
webSocketUrl?: string
|
|
17
|
+
/** Custom HTTP base URL */
|
|
18
|
+
httpUrl?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_CONFIG: TransportConfig = {
|
|
22
|
+
enableWebTransport: true,
|
|
23
|
+
enableWebSocket: true,
|
|
24
|
+
enableHTTP: true,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Manages transport selection with automatic fallback.
|
|
29
|
+
* Tries transports in order: WebTransport → WebSocket → HTTP
|
|
30
|
+
*/
|
|
31
|
+
export class TransportManager {
|
|
32
|
+
private transport: Transport | null = null
|
|
33
|
+
private config: TransportConfig
|
|
34
|
+
private messageHandlers: ((msg: ServerMessage) => void)[] = []
|
|
35
|
+
private statusHandlers: ((connected: boolean) => void)[] = []
|
|
36
|
+
|
|
37
|
+
constructor(config: Partial<TransportConfig> = {}) {
|
|
38
|
+
this.config = { ...DEFAULT_CONFIG, ...config }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Connect using the best available transport with fallback.
|
|
43
|
+
*/
|
|
44
|
+
async connect(): Promise<void> {
|
|
45
|
+
const transports = this.getTransportPriority()
|
|
46
|
+
|
|
47
|
+
for (const TransportClass of transports) {
|
|
48
|
+
try {
|
|
49
|
+
console.log(`PyWire: Trying ${TransportClass.name}...`)
|
|
50
|
+
this.transport = new TransportClass()
|
|
51
|
+
|
|
52
|
+
// Forward message handlers
|
|
53
|
+
for (const handler of this.messageHandlers) {
|
|
54
|
+
this.transport.onMessage(handler)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Forward status changes
|
|
58
|
+
this.transport.onStatusChange((connected) => {
|
|
59
|
+
this.notifyStatusHandlers(connected)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
await this.transport.connect()
|
|
63
|
+
console.log(`PyWire: Connected via ${this.transport.name}`)
|
|
64
|
+
return
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.warn(`PyWire: ${TransportClass.name} failed, trying next...`, e)
|
|
67
|
+
this.transport = null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new Error('PyWire: All transports failed')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get transport classes in priority order based on config and browser support.
|
|
76
|
+
*/
|
|
77
|
+
private getTransportPriority(): (new (...args: unknown[]) => Transport)[] {
|
|
78
|
+
const transports: (new (...args: unknown[]) => Transport)[] = []
|
|
79
|
+
|
|
80
|
+
// WebTransport - only if supported and enabled
|
|
81
|
+
if (this.config.enableWebTransport && WebTransportTransport.isSupported()) {
|
|
82
|
+
// Also check if we're on HTTPS (required for WebTransport)
|
|
83
|
+
if (window.location.protocol === 'https:') {
|
|
84
|
+
transports.push(WebTransportTransport as unknown as new (...args: unknown[]) => Transport)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// WebSocket
|
|
89
|
+
if (this.config.enableWebSocket && typeof WebSocket !== 'undefined') {
|
|
90
|
+
transports.push(WebSocketTransport as unknown as new (...args: unknown[]) => Transport)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// HTTP (always available as final fallback)
|
|
94
|
+
if (this.config.enableHTTP) {
|
|
95
|
+
transports.push(HTTPTransport as unknown as new (...args: unknown[]) => Transport)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return transports
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Send a message through the active transport.
|
|
103
|
+
*/
|
|
104
|
+
send(message: object): void {
|
|
105
|
+
if (this.transport) {
|
|
106
|
+
this.transport.send(message)
|
|
107
|
+
} else {
|
|
108
|
+
console.warn('PyWire: No active transport')
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Register a message handler.
|
|
114
|
+
*/
|
|
115
|
+
onMessage(handler: (msg: ServerMessage) => void): void {
|
|
116
|
+
this.messageHandlers.push(handler)
|
|
117
|
+
if (this.transport) {
|
|
118
|
+
this.transport.onMessage(handler)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Register a connection status change handler.
|
|
124
|
+
*/
|
|
125
|
+
onStatusChange(handler: (connected: boolean) => void): void {
|
|
126
|
+
this.statusHandlers.push(handler)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private notifyStatusHandlers(connected: boolean): void {
|
|
130
|
+
for (const handler of this.statusHandlers) {
|
|
131
|
+
handler(connected)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Disconnect the active transport.
|
|
137
|
+
*/
|
|
138
|
+
disconnect(): void {
|
|
139
|
+
if (this.transport) {
|
|
140
|
+
this.transport.disconnect()
|
|
141
|
+
this.transport = null
|
|
142
|
+
this.notifyStatusHandlers(false)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get the name of the active transport.
|
|
148
|
+
*/
|
|
149
|
+
getActiveTransport(): string | null {
|
|
150
|
+
return this.transport?.name || null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Check if connected.
|
|
155
|
+
*/
|
|
156
|
+
isConnected(): boolean {
|
|
157
|
+
return this.transport?.isConnected() || false
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base transport interface for all transport implementations.
|
|
3
|
+
*/
|
|
4
|
+
export interface Transport {
|
|
5
|
+
/** Connect to the server */
|
|
6
|
+
connect(): Promise<void>
|
|
7
|
+
|
|
8
|
+
/** Send a message to the server */
|
|
9
|
+
send(message: object): void
|
|
10
|
+
|
|
11
|
+
/** Register a message handler */
|
|
12
|
+
onMessage(handler: MessageHandler): void
|
|
13
|
+
|
|
14
|
+
/** Register a status change handler */
|
|
15
|
+
onStatusChange(handler: (connected: boolean) => void): void
|
|
16
|
+
|
|
17
|
+
/** Disconnect from the server */
|
|
18
|
+
disconnect(): void
|
|
19
|
+
|
|
20
|
+
/** Check if connected */
|
|
21
|
+
isConnected(): boolean
|
|
22
|
+
|
|
23
|
+
/** Transport name for debugging */
|
|
24
|
+
readonly name: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type MessageHandler = (message: ServerMessage) => void
|
|
28
|
+
|
|
29
|
+
export interface StackFrame {
|
|
30
|
+
filename: string
|
|
31
|
+
lineno: number
|
|
32
|
+
name: string
|
|
33
|
+
line: string
|
|
34
|
+
colno?: number // Python 3.11+ column start
|
|
35
|
+
end_colno?: number // Python 3.11+ column end
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ServerMessage {
|
|
39
|
+
type: 'update' | 'reload' | 'error' | 'console' | 'error_trace'
|
|
40
|
+
html?: string
|
|
41
|
+
regions?: Array<{ region: string; html: string }>
|
|
42
|
+
error?: string
|
|
43
|
+
level?: 'info' | 'warn' | 'error'
|
|
44
|
+
lines?: string[]
|
|
45
|
+
trace?: StackFrame[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ConsoleMessage {
|
|
49
|
+
type: 'console'
|
|
50
|
+
level: 'info' | 'warn' | 'error'
|
|
51
|
+
lines: string[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ClientMessage = EventMessage | RelocateMessage
|
|
55
|
+
|
|
56
|
+
export interface EventMessage {
|
|
57
|
+
type: 'event'
|
|
58
|
+
handler: string
|
|
59
|
+
path: string
|
|
60
|
+
data: EventData
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RelocateMessage {
|
|
64
|
+
type: 'relocate'
|
|
65
|
+
path: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface EventData {
|
|
69
|
+
type: string
|
|
70
|
+
id?: string
|
|
71
|
+
value?: string
|
|
72
|
+
args?: Record<string, unknown>
|
|
73
|
+
[key: string]: unknown
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Abstract base class providing common transport functionality.
|
|
78
|
+
*/
|
|
79
|
+
export abstract class BaseTransport implements Transport {
|
|
80
|
+
protected messageHandlers: MessageHandler[] = []
|
|
81
|
+
protected statusHandlers: ((connected: boolean) => void)[] = []
|
|
82
|
+
protected connected = false
|
|
83
|
+
|
|
84
|
+
abstract readonly name: string
|
|
85
|
+
abstract connect(): Promise<void>
|
|
86
|
+
abstract send(message: object): void
|
|
87
|
+
abstract disconnect(): void
|
|
88
|
+
|
|
89
|
+
onMessage(handler: MessageHandler): void {
|
|
90
|
+
this.messageHandlers.push(handler)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
onStatusChange(handler: (connected: boolean) => void): void {
|
|
94
|
+
this.statusHandlers.push(handler)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
isConnected(): boolean {
|
|
98
|
+
return this.connected
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
protected notifyHandlers(message: ServerMessage): void {
|
|
102
|
+
for (const handler of this.messageHandlers) {
|
|
103
|
+
try {
|
|
104
|
+
handler(message)
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.error('PyWire: Error in message handler', e)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
protected notifyStatus(connected: boolean): void {
|
|
112
|
+
if (this.connected === connected) return
|
|
113
|
+
this.connected = connected
|
|
114
|
+
for (const handler of this.statusHandlers) {
|
|
115
|
+
try {
|
|
116
|
+
handler(connected)
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.error('PyWire: Error in status handler', e)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { BaseTransport, ServerMessage } from './base'
|
|
2
|
+
import { encode, decode } from '@msgpack/msgpack'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HTTP polling transport as a fallback when WebTransport and WebSocket are unavailable.
|
|
6
|
+
* Uses long-polling for receiving updates and POST for sending events.
|
|
7
|
+
*/
|
|
8
|
+
export class HTTPTransport extends BaseTransport {
|
|
9
|
+
readonly name = 'HTTP'
|
|
10
|
+
|
|
11
|
+
private polling = false
|
|
12
|
+
private pollAbortController: AbortController | null = null
|
|
13
|
+
private readonly baseUrl: string
|
|
14
|
+
private sessionId: string | null = null
|
|
15
|
+
|
|
16
|
+
constructor(baseUrl?: string) {
|
|
17
|
+
super()
|
|
18
|
+
this.baseUrl = baseUrl || `${window.location.origin}/_pywire`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async connect(): Promise<void> {
|
|
22
|
+
try {
|
|
23
|
+
// Initialize session with the server
|
|
24
|
+
const response = await fetch(`${this.baseUrl}/session`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: {
|
|
27
|
+
'Content-Type': 'application/x-msgpack',
|
|
28
|
+
Accept: 'application/x-msgpack',
|
|
29
|
+
},
|
|
30
|
+
body: encode({ path: window.location.pathname + window.location.search }),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
throw new Error(`HTTP session init failed: ${response.status}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const buffer = await response.arrayBuffer()
|
|
38
|
+
const data = decode(buffer) as { sessionId: string }
|
|
39
|
+
this.sessionId = data.sessionId
|
|
40
|
+
|
|
41
|
+
console.log('PyWire: HTTP transport connected')
|
|
42
|
+
this.notifyStatus(true)
|
|
43
|
+
|
|
44
|
+
// Start polling for updates
|
|
45
|
+
this.startPolling()
|
|
46
|
+
} catch (e) {
|
|
47
|
+
console.error('PyWire: HTTP transport connection failed', e)
|
|
48
|
+
throw e
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private async startPolling(): Promise<void> {
|
|
53
|
+
if (this.polling) return
|
|
54
|
+
this.polling = true
|
|
55
|
+
|
|
56
|
+
while (this.polling && this.connected) {
|
|
57
|
+
try {
|
|
58
|
+
this.pollAbortController = new AbortController()
|
|
59
|
+
|
|
60
|
+
const response = await fetch(`${this.baseUrl}/poll?session=${this.sessionId}`, {
|
|
61
|
+
method: 'GET',
|
|
62
|
+
signal: this.pollAbortController.signal,
|
|
63
|
+
headers: {
|
|
64
|
+
Accept: 'application/x-msgpack',
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
if (response.status === 404) {
|
|
70
|
+
// Session expired, try to reconnect
|
|
71
|
+
console.warn('PyWire: HTTP session expired, reconnecting...')
|
|
72
|
+
this.notifyStatus(false)
|
|
73
|
+
await this.connect()
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`Poll failed: ${response.status}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const buffer = await response.arrayBuffer()
|
|
80
|
+
const messages = decode(buffer) as ServerMessage[]
|
|
81
|
+
|
|
82
|
+
for (const msg of messages) {
|
|
83
|
+
this.notifyHandlers(msg)
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (e instanceof Error && e.name === 'AbortError') {
|
|
87
|
+
// Polling was aborted intentionally
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
console.error('PyWire: HTTP poll error', e)
|
|
91
|
+
// Wait a bit before retrying
|
|
92
|
+
await this.sleep(1000)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async send(message: object): Promise<void> {
|
|
98
|
+
if (!this.connected || !this.sessionId) {
|
|
99
|
+
console.warn('PyWire: Cannot send message, HTTP transport not connected')
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetch(`${this.baseUrl}/event`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
'Content-Type': 'application/x-msgpack',
|
|
108
|
+
Accept: 'application/x-msgpack',
|
|
109
|
+
'X-PyWire-Session': this.sessionId,
|
|
110
|
+
},
|
|
111
|
+
body: encode(message),
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
throw new Error(`Event send failed: ${response.status}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The response contains the updated HTML
|
|
119
|
+
const buffer = await response.arrayBuffer()
|
|
120
|
+
const result = decode(buffer) as ServerMessage
|
|
121
|
+
this.notifyHandlers(result)
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.error('PyWire: HTTP send error', e)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
disconnect(): void {
|
|
128
|
+
this.polling = false
|
|
129
|
+
this.notifyStatus(false)
|
|
130
|
+
|
|
131
|
+
if (this.pollAbortController) {
|
|
132
|
+
this.pollAbortController.abort()
|
|
133
|
+
this.pollAbortController = null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.sessionId = null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private sleep(ms: number): Promise<void> {
|
|
140
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {
|
|
2
|
+
Transport,
|
|
3
|
+
MessageHandler,
|
|
4
|
+
ServerMessage,
|
|
5
|
+
ClientMessage,
|
|
6
|
+
EventMessage,
|
|
7
|
+
RelocateMessage,
|
|
8
|
+
EventData,
|
|
9
|
+
StackFrame,
|
|
10
|
+
} from './base'
|
|
11
|
+
export { WebSocketTransport } from './websocket'
|
|
12
|
+
export { WebTransportTransport } from './webtransport'
|
|
13
|
+
export { HTTPTransport } from './http'
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { BaseTransport, ServerMessage } from './base'
|
|
2
|
+
import { encode, decode } from '@msgpack/msgpack'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WebSocket transport implementation.
|
|
6
|
+
*/
|
|
7
|
+
export class WebSocketTransport extends BaseTransport {
|
|
8
|
+
readonly name = 'WebSocket'
|
|
9
|
+
|
|
10
|
+
private socket: WebSocket | null = null
|
|
11
|
+
private reconnectAttempts = 0
|
|
12
|
+
private maxReconnectDelay = 5000
|
|
13
|
+
private shouldReconnect = true
|
|
14
|
+
private readonly url: string
|
|
15
|
+
|
|
16
|
+
constructor(url?: string) {
|
|
17
|
+
super()
|
|
18
|
+
this.url = url || this.getDefaultUrl()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private getDefaultUrl(): string {
|
|
22
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
23
|
+
return `${protocol}//${window.location.host}/_pywire/ws`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
connect(): Promise<void> {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
try {
|
|
29
|
+
this.socket = new WebSocket(this.url)
|
|
30
|
+
this.socket.binaryType = 'arraybuffer'
|
|
31
|
+
|
|
32
|
+
this.socket.onopen = () => {
|
|
33
|
+
console.log('PyWire: WebSocket connected')
|
|
34
|
+
this.notifyStatus(true)
|
|
35
|
+
this.reconnectAttempts = 0
|
|
36
|
+
resolve()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.socket.onmessage = (event: MessageEvent) => {
|
|
40
|
+
try {
|
|
41
|
+
const msg = decode(event.data) as ServerMessage
|
|
42
|
+
this.notifyHandlers(msg)
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error('PyWire: Error parsing WebSocket message', e)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.socket.onclose = () => {
|
|
49
|
+
console.log('PyWire: WebSocket disconnected')
|
|
50
|
+
this.notifyStatus(false)
|
|
51
|
+
if (this.shouldReconnect) {
|
|
52
|
+
this.scheduleReconnect()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.socket.onerror = (error) => {
|
|
57
|
+
console.error('PyWire: WebSocket error', error)
|
|
58
|
+
if (!this.connected) {
|
|
59
|
+
reject(new Error('WebSocket connection failed'))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch (e) {
|
|
63
|
+
reject(e)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
send(message: object): void {
|
|
69
|
+
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
|
70
|
+
this.socket.send(encode(message))
|
|
71
|
+
} else {
|
|
72
|
+
console.warn('PyWire: Cannot send message, WebSocket not open')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
disconnect(): void {
|
|
77
|
+
this.shouldReconnect = false
|
|
78
|
+
if (this.socket) {
|
|
79
|
+
this.socket.close()
|
|
80
|
+
this.socket = null
|
|
81
|
+
}
|
|
82
|
+
this.notifyStatus(false)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private scheduleReconnect(): void {
|
|
86
|
+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay)
|
|
87
|
+
|
|
88
|
+
console.log(`PyWire: Reconnecting in ${delay}ms...`)
|
|
89
|
+
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
this.reconnectAttempts++
|
|
92
|
+
this.connect().catch(() => {
|
|
93
|
+
// Reconnect will be scheduled again on close
|
|
94
|
+
})
|
|
95
|
+
}, delay)
|
|
96
|
+
}
|
|
97
|
+
}
|