@reactpy/client 0.2.0 → 0.3.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/dist/components.d.ts +10 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +168 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +5 -0
- package/dist/messages.d.ts +15 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +1 -0
- package/dist/mount.d.ts +3 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +6 -0
- package/dist/reactpy-client.d.ts +92 -0
- package/dist/reactpy-client.d.ts.map +1 -0
- package/dist/reactpy-client.js +127 -0
- package/dist/reactpy-vdom.d.ts +54 -0
- package/dist/reactpy-vdom.d.ts.map +1 -0
- package/dist/reactpy-vdom.js +140 -0
- package/package.json +2 -5
- package/src/components.tsx +7 -11
- package/src/messages.ts +1 -16
- package/src/reactpy-client.ts +77 -90
- package/src/reactpy-vdom.tsx +2 -2
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/// <reference types="react" />
|
|
2
|
+
import { ReactPyVdom } from "./reactpy-vdom";
|
|
3
|
+
import { ReactPyClient } from "./reactpy-client";
|
|
4
|
+
export declare function Layout(props: {
|
|
5
|
+
client: ReactPyClient;
|
|
6
|
+
}): JSX.Element;
|
|
7
|
+
export declare function Element({ model }: {
|
|
8
|
+
model: ReactPyVdom;
|
|
9
|
+
}): JSX.Element | null;
|
|
10
|
+
//# sourceMappingURL=components.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"components.d.ts","sourceRoot":"","sources":["../src/components.tsx"],"names":[],"mappings":";AAaA,OAAO,EACL,WAAW,EAMZ,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAIjD,wBAAgB,MAAM,CAAC,KAAK,EAAE;IAAE,MAAM,EAAE,aAAa,CAAA;CAAE,GAAG,GAAG,CAAC,OAAO,CAsBpE;AAED,wBAAgB,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE;IAAE,KAAK,EAAE,WAAW,CAAA;CAAE,GAAG,GAAG,CAAC,OAAO,GAAG,IAAI,CAoB7E"}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import React, { createElement, createContext, useState, useRef, useContext, useEffect, Fragment, } from "react";
|
|
2
|
+
// @ts-ignore
|
|
3
|
+
import { set as setJsonPointer } from "json-pointer";
|
|
4
|
+
import { createChildren, createAttributes, loadImportSource, } from "./reactpy-vdom";
|
|
5
|
+
const ClientContext = createContext(null);
|
|
6
|
+
export function Layout(props) {
|
|
7
|
+
const currentModel = useState({ tagName: "" })[0];
|
|
8
|
+
const forceUpdate = useForceUpdate();
|
|
9
|
+
useEffect(() => props.client.onMessage("layout-update", ({ path, model }) => {
|
|
10
|
+
if (path === "") {
|
|
11
|
+
Object.assign(currentModel, model);
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
setJsonPointer(currentModel, path, model);
|
|
15
|
+
}
|
|
16
|
+
forceUpdate();
|
|
17
|
+
}), [currentModel, props.client]);
|
|
18
|
+
return (React.createElement(ClientContext.Provider, { value: props.client },
|
|
19
|
+
React.createElement(Element, { model: currentModel })));
|
|
20
|
+
}
|
|
21
|
+
export function Element({ model }) {
|
|
22
|
+
if (model.error !== undefined) {
|
|
23
|
+
if (model.error) {
|
|
24
|
+
return React.createElement("pre", null, model.error);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
let SpecializedElement;
|
|
31
|
+
if (model.tagName in SPECIAL_ELEMENTS) {
|
|
32
|
+
SpecializedElement =
|
|
33
|
+
SPECIAL_ELEMENTS[model.tagName];
|
|
34
|
+
}
|
|
35
|
+
else if (model.importSource) {
|
|
36
|
+
SpecializedElement = ImportedElement;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
SpecializedElement = StandardElement;
|
|
40
|
+
}
|
|
41
|
+
return React.createElement(SpecializedElement, { model: model });
|
|
42
|
+
}
|
|
43
|
+
function StandardElement({ model }) {
|
|
44
|
+
const client = React.useContext(ClientContext);
|
|
45
|
+
// Use createElement here to avoid warning about variable numbers of children not
|
|
46
|
+
// having keys. Warning about this must now be the responsibility of the client
|
|
47
|
+
// providing the models instead of the client rendering them.
|
|
48
|
+
return createElement(model.tagName === "" ? Fragment : model.tagName, createAttributes(model, client), ...createChildren(model, (child) => {
|
|
49
|
+
return React.createElement(Element, { model: child, key: child.key });
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
function UserInputElement({ model }) {
|
|
53
|
+
const client = useContext(ClientContext);
|
|
54
|
+
const props = createAttributes(model, client);
|
|
55
|
+
const [value, setValue] = React.useState(props.value);
|
|
56
|
+
// honor changes to value from the client via props
|
|
57
|
+
React.useEffect(() => setValue(props.value), [props.value]);
|
|
58
|
+
const givenOnChange = props.onChange;
|
|
59
|
+
if (typeof givenOnChange === "function") {
|
|
60
|
+
props.onChange = (event) => {
|
|
61
|
+
// immediately update the value to give the user feedback
|
|
62
|
+
setValue(event.target.value);
|
|
63
|
+
// allow the client to respond (and possibly change the value)
|
|
64
|
+
givenOnChange(event);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Use createElement here to avoid warning about variable numbers of children not
|
|
68
|
+
// having keys. Warning about this must now be the responsibility of the client
|
|
69
|
+
// providing the models instead of the client rendering them.
|
|
70
|
+
return createElement(model.tagName,
|
|
71
|
+
// overwrite
|
|
72
|
+
{ ...props, value }, ...createChildren(model, (child) => (React.createElement(Element, { model: child, key: child.key }))));
|
|
73
|
+
}
|
|
74
|
+
function ScriptElement({ model }) {
|
|
75
|
+
const ref = useRef(null);
|
|
76
|
+
React.useEffect(() => {
|
|
77
|
+
if (!ref.current) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const scriptContent = model?.children?.filter((value) => typeof value == "string")[0];
|
|
81
|
+
let scriptElement;
|
|
82
|
+
if (model.attributes) {
|
|
83
|
+
scriptElement = document.createElement("script");
|
|
84
|
+
for (const [k, v] of Object.entries(model.attributes)) {
|
|
85
|
+
scriptElement.setAttribute(k, v);
|
|
86
|
+
}
|
|
87
|
+
if (scriptContent) {
|
|
88
|
+
scriptElement.appendChild(document.createTextNode(scriptContent));
|
|
89
|
+
}
|
|
90
|
+
ref.current.appendChild(scriptElement);
|
|
91
|
+
}
|
|
92
|
+
else if (scriptContent) {
|
|
93
|
+
const scriptResult = eval(scriptContent);
|
|
94
|
+
if (typeof scriptResult == "function") {
|
|
95
|
+
return scriptResult();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}, [model.key, ref.current]);
|
|
99
|
+
return React.createElement("div", { ref: ref });
|
|
100
|
+
}
|
|
101
|
+
function ImportedElement({ model }) {
|
|
102
|
+
const importSourceVdom = model.importSource;
|
|
103
|
+
const importSourceRef = useImportSource(model);
|
|
104
|
+
if (!importSourceVdom) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const importSourceFallback = importSourceVdom.fallback;
|
|
108
|
+
if (!importSourceVdom) {
|
|
109
|
+
// display a fallback if one was given
|
|
110
|
+
if (!importSourceFallback) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
else if (typeof importSourceFallback === "string") {
|
|
114
|
+
return React.createElement("div", null, importSourceFallback);
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
return React.createElement(StandardElement, { model: importSourceFallback });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
return React.createElement("div", { ref: importSourceRef });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function useForceUpdate() {
|
|
125
|
+
const [, setState] = useState(false);
|
|
126
|
+
return () => setState((old) => !old);
|
|
127
|
+
}
|
|
128
|
+
function useImportSource(model) {
|
|
129
|
+
const vdomImportSource = model.importSource;
|
|
130
|
+
const mountPoint = useRef(null);
|
|
131
|
+
const client = React.useContext(ClientContext);
|
|
132
|
+
const [binding, setBinding] = useState(null);
|
|
133
|
+
React.useEffect(() => {
|
|
134
|
+
let unmounted = false;
|
|
135
|
+
if (vdomImportSource) {
|
|
136
|
+
loadImportSource(vdomImportSource, client).then((bind) => {
|
|
137
|
+
if (!unmounted && mountPoint.current) {
|
|
138
|
+
setBinding(bind(mountPoint.current));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return () => {
|
|
143
|
+
unmounted = true;
|
|
144
|
+
if (binding &&
|
|
145
|
+
vdomImportSource &&
|
|
146
|
+
!vdomImportSource.unmountBeforeUpdate) {
|
|
147
|
+
binding.unmount();
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}, [client, vdomImportSource, setBinding, mountPoint.current]);
|
|
151
|
+
// this effect must run every time in case the model has changed
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (!(binding && vdomImportSource)) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
binding.render(model);
|
|
157
|
+
if (vdomImportSource.unmountBeforeUpdate) {
|
|
158
|
+
return binding.unmount;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
return mountPoint;
|
|
162
|
+
}
|
|
163
|
+
const SPECIAL_ELEMENTS = {
|
|
164
|
+
input: UserInputElement,
|
|
165
|
+
script: ScriptElement,
|
|
166
|
+
select: UserInputElement,
|
|
167
|
+
textarea: UserInputElement,
|
|
168
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,YAAY,CAAC;AAC3B,cAAc,SAAS,CAAC;AACxB,cAAc,kBAAkB,CAAC;AACjC,cAAc,gBAAgB,CAAC"}
|
package/dist/index.js
ADDED
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.d.ts","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":";mBACiB,GAAG,EAAE,KAAG,IAAI;oBACX,GAAG,EAAE,KAAG,IAAI;qBACX,GAAG,EAAE,KAAG,IAAI;;AAH/B,wBAIE"}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ReactPyVdom } from "./reactpy-vdom";
|
|
2
|
+
export type LayoutUpdateMessage = {
|
|
3
|
+
type: "layout-update";
|
|
4
|
+
path: string;
|
|
5
|
+
model: ReactPyVdom;
|
|
6
|
+
};
|
|
7
|
+
export type LayoutEventMessage = {
|
|
8
|
+
type: "layout-event";
|
|
9
|
+
target: string;
|
|
10
|
+
data: any;
|
|
11
|
+
};
|
|
12
|
+
export type IncomingMessage = LayoutUpdateMessage;
|
|
13
|
+
export type OutgoingMessage = LayoutEventMessage;
|
|
14
|
+
export type Message = IncomingMessage | OutgoingMessage;
|
|
15
|
+
//# sourceMappingURL=messages.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../src/messages.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAE7C,MAAM,MAAM,mBAAmB,GAAG;IAChC,IAAI,EAAE,eAAe,CAAC;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,WAAW,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,cAAc,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,GAAG,CAAC;CACX,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG,mBAAmB,CAAC;AAClD,MAAM,MAAM,eAAe,GAAG,kBAAkB,CAAC;AACjD,MAAM,MAAM,OAAO,GAAG,eAAe,GAAG,eAAe,CAAC"}
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/mount.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mount.d.ts","sourceRoot":"","sources":["../src/mount.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEjD,wBAAgB,KAAK,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,GAAG,IAAI,CAEvE"}
|
package/dist/mount.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { ReactPyModule } from "./reactpy-vdom";
|
|
2
|
+
/**
|
|
3
|
+
* A client for communicating with a ReactPy server.
|
|
4
|
+
*/
|
|
5
|
+
export interface ReactPyClient {
|
|
6
|
+
/**
|
|
7
|
+
* Register a handler for a message type.
|
|
8
|
+
*
|
|
9
|
+
* The first time this is called, the client will be considered ready.
|
|
10
|
+
*
|
|
11
|
+
* @param type The type of message to handle.
|
|
12
|
+
* @param handler The handler to call when a message of the given type is received.
|
|
13
|
+
* @returns A function to unregister the handler.
|
|
14
|
+
*/
|
|
15
|
+
onMessage(type: string, handler: (message: any) => void): () => void;
|
|
16
|
+
/**
|
|
17
|
+
* Send a message to the server.
|
|
18
|
+
*
|
|
19
|
+
* @param message The message to send. Messages must have a `type` property.
|
|
20
|
+
*/
|
|
21
|
+
sendMessage: (message: any) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Load a module from the server.
|
|
24
|
+
* @param moduleName The name of the module to load.
|
|
25
|
+
* @returns A promise that resolves to the module.
|
|
26
|
+
*/
|
|
27
|
+
loadModule: (moduleName: string) => Promise<ReactPyModule>;
|
|
28
|
+
}
|
|
29
|
+
export declare class BaseReactPyClient implements Partial<ReactPyClient> {
|
|
30
|
+
private readonly handlers;
|
|
31
|
+
protected readonly ready: Promise<void>;
|
|
32
|
+
private resolveReady;
|
|
33
|
+
constructor();
|
|
34
|
+
onMessage(type: string, handler: (message: any) => void): () => void;
|
|
35
|
+
/**
|
|
36
|
+
* Handle an incoming message.
|
|
37
|
+
*
|
|
38
|
+
* This should be called by subclasses when a message is received.
|
|
39
|
+
*
|
|
40
|
+
* @param message The message to handle. The message must have a `type` property.
|
|
41
|
+
*/
|
|
42
|
+
protected handleIncoming(message: any): void;
|
|
43
|
+
}
|
|
44
|
+
export type SimpleReactPyClientProps = {
|
|
45
|
+
serverLocation?: LocationProps;
|
|
46
|
+
reconnectOptions?: ReconnectProps;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* The location of the server.
|
|
50
|
+
*
|
|
51
|
+
* This is used to determine the location of the server's API endpoints. All endpoints
|
|
52
|
+
* are expected to be found at the base URL, with the following paths:
|
|
53
|
+
*
|
|
54
|
+
* - `_reactpy/stream/${route}${query}`: The websocket endpoint for the stream.
|
|
55
|
+
* - `_reactpy/modules`: The directory containing the dynamically loaded modules.
|
|
56
|
+
* - `_reactpy/assets`: The directory containing the static assets.
|
|
57
|
+
*/
|
|
58
|
+
type LocationProps = {
|
|
59
|
+
/**
|
|
60
|
+
* The base URL of the server.
|
|
61
|
+
*
|
|
62
|
+
* @default - document.location.origin
|
|
63
|
+
*/
|
|
64
|
+
url: string;
|
|
65
|
+
/**
|
|
66
|
+
* The route to the page being rendered.
|
|
67
|
+
*
|
|
68
|
+
* @default - document.location.pathname
|
|
69
|
+
*/
|
|
70
|
+
route: string;
|
|
71
|
+
/**
|
|
72
|
+
* The query string of the page being rendered.
|
|
73
|
+
*
|
|
74
|
+
* @default - document.location.search
|
|
75
|
+
*/
|
|
76
|
+
query: string;
|
|
77
|
+
};
|
|
78
|
+
type ReconnectProps = {
|
|
79
|
+
maxInterval?: number;
|
|
80
|
+
maxRetries?: number;
|
|
81
|
+
backoffRate?: number;
|
|
82
|
+
intervalJitter?: number;
|
|
83
|
+
};
|
|
84
|
+
export declare class SimpleReactPyClient extends BaseReactPyClient implements ReactPyClient {
|
|
85
|
+
private readonly urls;
|
|
86
|
+
private readonly socket;
|
|
87
|
+
constructor(props: SimpleReactPyClientProps);
|
|
88
|
+
sendMessage(message: any): void;
|
|
89
|
+
loadModule(moduleName: string): Promise<ReactPyModule>;
|
|
90
|
+
}
|
|
91
|
+
export {};
|
|
92
|
+
//# sourceMappingURL=reactpy-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reactpy-client.d.ts","sourceRoot":"","sources":["../src/reactpy-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAG/C;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;;;;;OAQG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,GAAG,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IAErE;;;;OAIG;IACH,WAAW,EAAE,CAAC,OAAO,EAAE,GAAG,KAAK,IAAI,CAAC;IAEpC;;;;OAIG;IACH,UAAU,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,aAAa,CAAC,CAAC;CAC5D;AAED,qBAAa,iBAAkB,YAAW,OAAO,CAAC,aAAa,CAAC;IAC9D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqD;IAC9E,SAAS,CAAC,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,OAAO,CAAC,YAAY,CAA6B;;IAOjD,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,GAAG,KAAK,IAAI,GAAG,MAAM,IAAI;IAQpE;;;;;;OAMG;IACH,SAAS,CAAC,cAAc,CAAC,OAAO,EAAE,GAAG,GAAG,IAAI;CAe7C;AAED,MAAM,MAAM,wBAAwB,GAAG;IACrC,cAAc,CAAC,EAAE,aAAa,CAAC;IAC/B,gBAAgB,CAAC,EAAE,cAAc,CAAC;CACnC,CAAC;AAEF;;;;;;;;;GASG;AACH,KAAK,aAAa,GAAG;IACnB;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAC;IACZ;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,KAAK,cAAc,GAAG;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAEF,qBAAa,mBACX,SAAQ,iBACR,YAAW,aAAa;IAExB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAa;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA0B;gBAErC,KAAK,EAAE,wBAAwB;IAmB3C,WAAW,CAAC,OAAO,EAAE,GAAG,GAAG,IAAI;IAI/B,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;CAGvD"}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import logger from "./logger";
|
|
2
|
+
export class BaseReactPyClient {
|
|
3
|
+
handlers = {};
|
|
4
|
+
ready;
|
|
5
|
+
resolveReady;
|
|
6
|
+
constructor() {
|
|
7
|
+
this.resolveReady = () => { };
|
|
8
|
+
this.ready = new Promise((resolve) => (this.resolveReady = resolve));
|
|
9
|
+
}
|
|
10
|
+
onMessage(type, handler) {
|
|
11
|
+
(this.handlers[type] || (this.handlers[type] = [])).push(handler);
|
|
12
|
+
this.resolveReady(undefined);
|
|
13
|
+
return () => {
|
|
14
|
+
this.handlers[type] = this.handlers[type].filter((h) => h !== handler);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Handle an incoming message.
|
|
19
|
+
*
|
|
20
|
+
* This should be called by subclasses when a message is received.
|
|
21
|
+
*
|
|
22
|
+
* @param message The message to handle. The message must have a `type` property.
|
|
23
|
+
*/
|
|
24
|
+
handleIncoming(message) {
|
|
25
|
+
if (!message.type) {
|
|
26
|
+
logger.warn("Received message without type", message);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const messageHandlers = this.handlers[message.type];
|
|
30
|
+
if (!messageHandlers) {
|
|
31
|
+
logger.warn("Received message without handler", message);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
messageHandlers.forEach((h) => h(message));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export class SimpleReactPyClient extends BaseReactPyClient {
|
|
38
|
+
urls;
|
|
39
|
+
socket;
|
|
40
|
+
constructor(props) {
|
|
41
|
+
super();
|
|
42
|
+
this.urls = getServerUrls(props.serverLocation || {
|
|
43
|
+
url: document.location.origin,
|
|
44
|
+
route: document.location.pathname,
|
|
45
|
+
query: document.location.search,
|
|
46
|
+
});
|
|
47
|
+
this.socket = createReconnectingWebSocket({
|
|
48
|
+
readyPromise: this.ready,
|
|
49
|
+
url: this.urls.stream,
|
|
50
|
+
onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)),
|
|
51
|
+
...props.reconnectOptions,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
sendMessage(message) {
|
|
55
|
+
this.socket.current?.send(JSON.stringify(message));
|
|
56
|
+
}
|
|
57
|
+
loadModule(moduleName) {
|
|
58
|
+
return import(`${this.urls.modules}/${moduleName}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function getServerUrls(props) {
|
|
62
|
+
const base = new URL(`${props.url || document.location.origin}/_reactpy`);
|
|
63
|
+
const modules = `${base}/modules`;
|
|
64
|
+
const assets = `${base}/assets`;
|
|
65
|
+
const streamProtocol = `ws${base.protocol === "https:" ? "s" : ""}`;
|
|
66
|
+
const streamPath = rtrim(`${base.pathname}/stream${props.route || ""}`, "/");
|
|
67
|
+
const stream = `${streamProtocol}://${base.host}${streamPath}${props.query}`;
|
|
68
|
+
return { base, modules, assets, stream };
|
|
69
|
+
}
|
|
70
|
+
function createReconnectingWebSocket(props) {
|
|
71
|
+
const { maxInterval = 60000, maxRetries = 50, backoffRate = 1.1, intervalJitter = 0.1, } = props;
|
|
72
|
+
const startInterval = 750;
|
|
73
|
+
let retries = 0;
|
|
74
|
+
let interval = startInterval;
|
|
75
|
+
const closed = false;
|
|
76
|
+
let everConnected = false;
|
|
77
|
+
const socket = {};
|
|
78
|
+
const connect = () => {
|
|
79
|
+
if (closed) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
socket.current = new WebSocket(props.url);
|
|
83
|
+
socket.current.onopen = () => {
|
|
84
|
+
everConnected = true;
|
|
85
|
+
logger.log("client connected");
|
|
86
|
+
interval = startInterval;
|
|
87
|
+
retries = 0;
|
|
88
|
+
if (props.onOpen) {
|
|
89
|
+
props.onOpen();
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
socket.current.onmessage = props.onMessage;
|
|
93
|
+
socket.current.onclose = () => {
|
|
94
|
+
if (!everConnected) {
|
|
95
|
+
logger.log("failed to connect");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
logger.log("client disconnected");
|
|
99
|
+
if (props.onClose) {
|
|
100
|
+
props.onClose();
|
|
101
|
+
}
|
|
102
|
+
if (retries >= maxRetries) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const thisInterval = addJitter(interval, intervalJitter);
|
|
106
|
+
logger.log(`reconnecting in ${(thisInterval / 1000).toPrecision(4)} seconds...`);
|
|
107
|
+
setTimeout(connect, thisInterval);
|
|
108
|
+
interval = nextInterval(interval, backoffRate, maxInterval);
|
|
109
|
+
retries++;
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
props.readyPromise.then(() => logger.log("starting client...")).then(connect);
|
|
113
|
+
return socket;
|
|
114
|
+
}
|
|
115
|
+
function nextInterval(currentInterval, backoffRate, maxInterval) {
|
|
116
|
+
return Math.min(currentInterval *
|
|
117
|
+
// increase interval by backoff rate
|
|
118
|
+
backoffRate,
|
|
119
|
+
// don't exceed max interval
|
|
120
|
+
maxInterval);
|
|
121
|
+
}
|
|
122
|
+
function addJitter(interval, jitter) {
|
|
123
|
+
return interval + (Math.random() * jitter * interval * 2 - jitter * interval);
|
|
124
|
+
}
|
|
125
|
+
function rtrim(text, trim) {
|
|
126
|
+
return text.replace(new RegExp(`${trim}+$`), "");
|
|
127
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ComponentType } from "react";
|
|
2
|
+
import { ReactPyClient } from "./reactpy-client";
|
|
3
|
+
export declare function loadImportSource(vdomImportSource: ReactPyVdomImportSource, client: ReactPyClient): Promise<BindImportSource>;
|
|
4
|
+
export declare function createChildren<Child>(model: ReactPyVdom, createChild: (child: ReactPyVdom) => Child): (Child | string)[];
|
|
5
|
+
export declare function createAttributes(model: ReactPyVdom, client: ReactPyClient): {
|
|
6
|
+
[key: string]: any;
|
|
7
|
+
};
|
|
8
|
+
export type ReactPyComponent = ComponentType<{
|
|
9
|
+
model: ReactPyVdom;
|
|
10
|
+
}>;
|
|
11
|
+
export type ReactPyVdom = {
|
|
12
|
+
tagName: string;
|
|
13
|
+
key?: string;
|
|
14
|
+
attributes?: {
|
|
15
|
+
[key: string]: string;
|
|
16
|
+
};
|
|
17
|
+
children?: (ReactPyVdom | string)[];
|
|
18
|
+
error?: string;
|
|
19
|
+
eventHandlers?: {
|
|
20
|
+
[key: string]: ReactPyVdomEventHandler;
|
|
21
|
+
};
|
|
22
|
+
importSource?: ReactPyVdomImportSource;
|
|
23
|
+
};
|
|
24
|
+
export type ReactPyVdomEventHandler = {
|
|
25
|
+
target: string;
|
|
26
|
+
preventDefault?: boolean;
|
|
27
|
+
stopPropagation?: boolean;
|
|
28
|
+
};
|
|
29
|
+
export type ReactPyVdomImportSource = {
|
|
30
|
+
source: string;
|
|
31
|
+
sourceType?: "URL" | "NAME";
|
|
32
|
+
fallback?: string | ReactPyVdom;
|
|
33
|
+
unmountBeforeUpdate?: boolean;
|
|
34
|
+
};
|
|
35
|
+
export type ReactPyModule = {
|
|
36
|
+
bind: (node: HTMLElement, context: ReactPyModuleBindingContext) => ReactPyModuleBinding;
|
|
37
|
+
} & {
|
|
38
|
+
[key: string]: any;
|
|
39
|
+
};
|
|
40
|
+
export type ReactPyModuleBindingContext = {
|
|
41
|
+
sendMessage: ReactPyClient["sendMessage"];
|
|
42
|
+
onMessage: ReactPyClient["onMessage"];
|
|
43
|
+
};
|
|
44
|
+
export type ReactPyModuleBinding = {
|
|
45
|
+
create: (type: any, props?: any, children?: (any | string | ReactPyVdom)[]) => any;
|
|
46
|
+
render: (element: any) => void;
|
|
47
|
+
unmount: () => void;
|
|
48
|
+
};
|
|
49
|
+
export type BindImportSource = (node: HTMLElement) => ImportSourceBinding | null;
|
|
50
|
+
export type ImportSourceBinding = {
|
|
51
|
+
render: (model: ReactPyVdom) => void;
|
|
52
|
+
unmount: () => void;
|
|
53
|
+
};
|
|
54
|
+
//# sourceMappingURL=reactpy-vdom.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reactpy-vdom.d.ts","sourceRoot":"","sources":["../src/reactpy-vdom.tsx"],"names":[],"mappings":"AAAA,OAAc,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGjD,wBAAsB,gBAAgB,CACpC,gBAAgB,EAAE,uBAAuB,EACzC,MAAM,EAAE,aAAa,GACpB,OAAO,CAAC,gBAAgB,CAAC,CA2C3B;AA+DD,wBAAgB,cAAc,CAAC,KAAK,EAClC,KAAK,EAAE,WAAW,EAClB,WAAW,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,KAAK,GACzC,CAAC,KAAK,GAAG,MAAM,CAAC,EAAE,CAapB;AAED,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,aAAa,GACpB;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CAAE,CAcxB;AA0DD,MAAM,MAAM,gBAAgB,GAAG,aAAa,CAAC;IAAE,KAAK,EAAE,WAAW,CAAA;CAAE,CAAC,CAAC;AAErE,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACvC,QAAQ,CAAC,EAAE,CAAC,WAAW,GAAG,MAAM,CAAC,EAAE,CAAC;IACpC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,uBAAuB,CAAA;KAAE,CAAC;IAC3D,YAAY,CAAC,EAAE,uBAAuB,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,KAAK,GAAG,MAAM,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,GAAG,WAAW,CAAC;IAChC,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,IAAI,EAAE,CACJ,IAAI,EAAE,WAAW,EACjB,OAAO,EAAE,2BAA2B,KACjC,oBAAoB,CAAC;CAC3B,GAAG;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CAAE,CAAC;AAE3B,MAAM,MAAM,2BAA2B,GAAG;IACxC,WAAW,EAAE,aAAa,CAAC,aAAa,CAAC,CAAC;IAC1C,SAAS,EAAE,aAAa,CAAC,WAAW,CAAC,CAAC;CACvC,CAAC;AAEF,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,CACN,IAAI,EAAE,GAAG,EACT,KAAK,CAAC,EAAE,GAAG,EACX,QAAQ,CAAC,EAAE,CAAC,GAAG,GAAG,MAAM,GAAG,WAAW,CAAC,EAAE,KACtC,GAAG,CAAC;IACT,MAAM,EAAE,CAAC,OAAO,EAAE,GAAG,KAAK,IAAI,CAAC;IAC/B,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG,CAC7B,IAAI,EAAE,WAAW,KACd,mBAAmB,GAAG,IAAI,CAAC;AAEhC,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACrC,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB,CAAC"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import serializeEvent from "event-to-object";
|
|
2
|
+
export async function loadImportSource(vdomImportSource, client) {
|
|
3
|
+
let module;
|
|
4
|
+
if (vdomImportSource.sourceType === "URL") {
|
|
5
|
+
module = await import(vdomImportSource.source);
|
|
6
|
+
}
|
|
7
|
+
else {
|
|
8
|
+
module = await client.loadModule(vdomImportSource.source);
|
|
9
|
+
}
|
|
10
|
+
if (typeof module.bind !== "function") {
|
|
11
|
+
throw new Error(`${vdomImportSource.source} did not export a function 'bind'`);
|
|
12
|
+
}
|
|
13
|
+
return (node) => {
|
|
14
|
+
const binding = module.bind(node, {
|
|
15
|
+
sendMessage: client.sendMessage,
|
|
16
|
+
onMessage: client.onMessage,
|
|
17
|
+
});
|
|
18
|
+
if (!(typeof binding.create === "function" &&
|
|
19
|
+
typeof binding.render === "function" &&
|
|
20
|
+
typeof binding.unmount === "function")) {
|
|
21
|
+
console.error(`${vdomImportSource.source} returned an impropper binding`);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
render: (model) => binding.render(createImportSourceElement({
|
|
26
|
+
client,
|
|
27
|
+
module,
|
|
28
|
+
binding,
|
|
29
|
+
model,
|
|
30
|
+
currentImportSource: vdomImportSource,
|
|
31
|
+
})),
|
|
32
|
+
unmount: binding.unmount,
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function createImportSourceElement(props) {
|
|
37
|
+
let type;
|
|
38
|
+
if (props.model.importSource) {
|
|
39
|
+
if (!isImportSourceEqual(props.currentImportSource, props.model.importSource)) {
|
|
40
|
+
console.error("Parent element import source " +
|
|
41
|
+
stringifyImportSource(props.currentImportSource) +
|
|
42
|
+
" does not match child's import source " +
|
|
43
|
+
stringifyImportSource(props.model.importSource));
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
else if (!props.module[props.model.tagName]) {
|
|
47
|
+
console.error("Module from source " +
|
|
48
|
+
stringifyImportSource(props.currentImportSource) +
|
|
49
|
+
` does not export ${props.model.tagName}`);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
type = props.module[props.model.tagName];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
type = props.model.tagName;
|
|
58
|
+
}
|
|
59
|
+
return props.binding.create(type, createAttributes(props.model, props.client), createChildren(props.model, (child) => createImportSourceElement({
|
|
60
|
+
...props,
|
|
61
|
+
model: child,
|
|
62
|
+
})));
|
|
63
|
+
}
|
|
64
|
+
function isImportSourceEqual(source1, source2) {
|
|
65
|
+
return (source1.source === source2.source &&
|
|
66
|
+
source1.sourceType === source2.sourceType);
|
|
67
|
+
}
|
|
68
|
+
function stringifyImportSource(importSource) {
|
|
69
|
+
return JSON.stringify({
|
|
70
|
+
source: importSource.source,
|
|
71
|
+
sourceType: importSource.sourceType,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
export function createChildren(model, createChild) {
|
|
75
|
+
if (!model.children) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
return model.children.map((child) => {
|
|
80
|
+
switch (typeof child) {
|
|
81
|
+
case "object":
|
|
82
|
+
return createChild(child);
|
|
83
|
+
case "string":
|
|
84
|
+
return child;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function createAttributes(model, client) {
|
|
90
|
+
return Object.fromEntries(Object.entries({
|
|
91
|
+
// Normal HTML attributes
|
|
92
|
+
...model.attributes,
|
|
93
|
+
// Construct event handlers
|
|
94
|
+
...Object.fromEntries(Object.entries(model.eventHandlers || {}).map(([name, handler]) => createEventHandler(client, name, handler))),
|
|
95
|
+
// Convert snake_case to camelCase names
|
|
96
|
+
}).map(normalizeAttribute));
|
|
97
|
+
}
|
|
98
|
+
function createEventHandler(client, name, { target, preventDefault, stopPropagation }) {
|
|
99
|
+
return [
|
|
100
|
+
name,
|
|
101
|
+
function (...args) {
|
|
102
|
+
const data = Array.from(args).map((value) => {
|
|
103
|
+
if (!(typeof value === "object" && value.nativeEvent)) {
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
const event = value;
|
|
107
|
+
if (preventDefault) {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
}
|
|
110
|
+
if (stopPropagation) {
|
|
111
|
+
event.stopPropagation();
|
|
112
|
+
}
|
|
113
|
+
return serializeEvent(event.nativeEvent);
|
|
114
|
+
});
|
|
115
|
+
client.sendMessage({ type: "layout-event", data, target });
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
function normalizeAttribute([key, value]) {
|
|
120
|
+
let normKey = key;
|
|
121
|
+
let normValue = value;
|
|
122
|
+
if (key === "style" && typeof value === "object") {
|
|
123
|
+
normValue = Object.fromEntries(Object.entries(value).map(([k, v]) => [snakeToCamel(k), v]));
|
|
124
|
+
}
|
|
125
|
+
else if (key.startsWith("data_") ||
|
|
126
|
+
key.startsWith("aria_") ||
|
|
127
|
+
DASHED_HTML_ATTRS.includes(key)) {
|
|
128
|
+
normKey = key.split("_").join("-");
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
normKey = snakeToCamel(key);
|
|
132
|
+
}
|
|
133
|
+
return [normKey, normValue];
|
|
134
|
+
}
|
|
135
|
+
function snakeToCamel(str) {
|
|
136
|
+
return str.replace(/([_][a-z])/g, (group) => group.toUpperCase().replace("_", ""));
|
|
137
|
+
}
|
|
138
|
+
// see list of HTML attributes with dashes in them:
|
|
139
|
+
// https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes#attribute_list
|
|
140
|
+
const DASHED_HTML_ATTRS = ["accept_charset", "http_equiv"];
|
package/package.json
CHANGED
|
@@ -6,16 +6,15 @@
|
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"name": "@reactpy/client",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.3.0",
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"event-to-object": "^0.1.
|
|
11
|
+
"event-to-object": "^0.1.2",
|
|
12
12
|
"json-pointer": "^0.6.2"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@types/json-pointer": "^1.0.31",
|
|
16
16
|
"@types/react": "^17.0",
|
|
17
17
|
"@types/react-dom": "^17.0",
|
|
18
|
-
"prettier": "^3.0.0-alpha.6",
|
|
19
18
|
"typescript": "^4.9.5"
|
|
20
19
|
},
|
|
21
20
|
"peerDependencies": {
|
|
@@ -28,9 +27,7 @@
|
|
|
28
27
|
},
|
|
29
28
|
"scripts": {
|
|
30
29
|
"build": "tsc -b",
|
|
31
|
-
"format": "prettier --write .",
|
|
32
30
|
"test": "npm run check:tests",
|
|
33
|
-
"check:format": "prettier --check .",
|
|
34
31
|
"check:tests": "echo 'no tests'",
|
|
35
32
|
"check:types": "tsc --noEmit"
|
|
36
33
|
}
|
package/src/components.tsx
CHANGED
|
@@ -11,7 +11,6 @@ import React, {
|
|
|
11
11
|
} from "react";
|
|
12
12
|
// @ts-ignore
|
|
13
13
|
import { set as setJsonPointer } from "json-pointer";
|
|
14
|
-
import { LayoutUpdateMessage } from "./messages";
|
|
15
14
|
import {
|
|
16
15
|
ReactPyVdom,
|
|
17
16
|
ReactPyComponent,
|
|
@@ -28,21 +27,18 @@ export function Layout(props: { client: ReactPyClient }): JSX.Element {
|
|
|
28
27
|
const currentModel: ReactPyVdom = useState({ tagName: "" })[0];
|
|
29
28
|
const forceUpdate = useForceUpdate();
|
|
30
29
|
|
|
31
|
-
useEffect(
|
|
32
|
-
|
|
33
|
-
"layout-update",
|
|
34
|
-
({ path, model }) => {
|
|
30
|
+
useEffect(
|
|
31
|
+
() =>
|
|
32
|
+
props.client.onMessage("layout-update", ({ path, model }) => {
|
|
35
33
|
if (path === "") {
|
|
36
34
|
Object.assign(currentModel, model);
|
|
37
35
|
} else {
|
|
38
36
|
setJsonPointer(currentModel, path, model);
|
|
39
37
|
}
|
|
40
38
|
forceUpdate();
|
|
41
|
-
},
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return () => props.client.stop();
|
|
45
|
-
}, [currentModel, props.client]);
|
|
39
|
+
}),
|
|
40
|
+
[currentModel, props.client],
|
|
41
|
+
);
|
|
46
42
|
|
|
47
43
|
return (
|
|
48
44
|
<ClientContext.Provider value={props.client}>
|
|
@@ -140,7 +136,7 @@ function ScriptElement({ model }: { model: ReactPyVdom }) {
|
|
|
140
136
|
}
|
|
141
137
|
ref.current.appendChild(scriptElement);
|
|
142
138
|
} else if (scriptContent) {
|
|
143
|
-
|
|
139
|
+
const scriptResult = eval(scriptContent);
|
|
144
140
|
if (typeof scriptResult == "function") {
|
|
145
141
|
return scriptResult();
|
|
146
142
|
}
|
package/src/messages.ts
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
import { ReactPyVdom } from "./reactpy-vdom";
|
|
2
2
|
|
|
3
|
-
export interface IMessage {
|
|
4
|
-
type: string;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export type ConnectionOpenMessage = {
|
|
8
|
-
type: "connection-open";
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export type ConnectionCloseMessage = {
|
|
12
|
-
type: "connection-close";
|
|
13
|
-
};
|
|
14
|
-
|
|
15
3
|
export type LayoutUpdateMessage = {
|
|
16
4
|
type: "layout-update";
|
|
17
5
|
path: string;
|
|
@@ -24,9 +12,6 @@ export type LayoutEventMessage = {
|
|
|
24
12
|
data: any;
|
|
25
13
|
};
|
|
26
14
|
|
|
27
|
-
export type IncomingMessage =
|
|
28
|
-
| LayoutUpdateMessage
|
|
29
|
-
| ConnectionOpenMessage
|
|
30
|
-
| ConnectionCloseMessage;
|
|
15
|
+
export type IncomingMessage = LayoutUpdateMessage;
|
|
31
16
|
export type OutgoingMessage = LayoutEventMessage;
|
|
32
17
|
export type Message = IncomingMessage | OutgoingMessage;
|
package/src/reactpy-client.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { OutgoingMessage, IncomingMessage } from "./messages";
|
|
2
1
|
import { ReactPyModule } from "./reactpy-vdom";
|
|
3
2
|
import logger from "./logger";
|
|
4
3
|
|
|
@@ -6,34 +5,74 @@ import logger from "./logger";
|
|
|
6
5
|
* A client for communicating with a ReactPy server.
|
|
7
6
|
*/
|
|
8
7
|
export interface ReactPyClient {
|
|
9
|
-
/**
|
|
10
|
-
* Connect to the server and start receiving messages.
|
|
11
|
-
*
|
|
12
|
-
* Message handlers should be registered before calling this method in order to
|
|
13
|
-
* garuntee that messages are not missed.
|
|
14
|
-
*/
|
|
15
|
-
start: () => void;
|
|
16
|
-
/**
|
|
17
|
-
* Disconnect from the server and stop receiving messages.
|
|
18
|
-
*/
|
|
19
|
-
stop: () => void;
|
|
20
8
|
/**
|
|
21
9
|
* Register a handler for a message type.
|
|
10
|
+
*
|
|
11
|
+
* The first time this is called, the client will be considered ready.
|
|
12
|
+
*
|
|
13
|
+
* @param type The type of message to handle.
|
|
14
|
+
* @param handler The handler to call when a message of the given type is received.
|
|
15
|
+
* @returns A function to unregister the handler.
|
|
22
16
|
*/
|
|
23
|
-
onMessage:
|
|
24
|
-
|
|
25
|
-
handler: (message: M) => void,
|
|
26
|
-
) => void;
|
|
17
|
+
onMessage(type: string, handler: (message: any) => void): () => void;
|
|
18
|
+
|
|
27
19
|
/**
|
|
28
20
|
* Send a message to the server.
|
|
21
|
+
*
|
|
22
|
+
* @param message The message to send. Messages must have a `type` property.
|
|
29
23
|
*/
|
|
30
|
-
sendMessage: (message:
|
|
24
|
+
sendMessage: (message: any) => void;
|
|
25
|
+
|
|
31
26
|
/**
|
|
32
|
-
*
|
|
27
|
+
* Load a module from the server.
|
|
28
|
+
* @param moduleName The name of the module to load.
|
|
29
|
+
* @returns A promise that resolves to the module.
|
|
33
30
|
*/
|
|
34
31
|
loadModule: (moduleName: string) => Promise<ReactPyModule>;
|
|
35
32
|
}
|
|
36
33
|
|
|
34
|
+
export class BaseReactPyClient implements Partial<ReactPyClient> {
|
|
35
|
+
private readonly handlers: { [key: string]: ((message: any) => void)[] } = {};
|
|
36
|
+
protected readonly ready: Promise<void>;
|
|
37
|
+
private resolveReady: (value: undefined) => void;
|
|
38
|
+
|
|
39
|
+
constructor() {
|
|
40
|
+
this.resolveReady = () => {};
|
|
41
|
+
this.ready = new Promise((resolve) => (this.resolveReady = resolve));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
onMessage(type: string, handler: (message: any) => void): () => void {
|
|
45
|
+
(this.handlers[type] || (this.handlers[type] = [])).push(handler);
|
|
46
|
+
this.resolveReady(undefined);
|
|
47
|
+
return () => {
|
|
48
|
+
this.handlers[type] = this.handlers[type].filter((h) => h !== handler);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Handle an incoming message.
|
|
54
|
+
*
|
|
55
|
+
* This should be called by subclasses when a message is received.
|
|
56
|
+
*
|
|
57
|
+
* @param message The message to handle. The message must have a `type` property.
|
|
58
|
+
*/
|
|
59
|
+
protected handleIncoming(message: any): void {
|
|
60
|
+
if (!message.type) {
|
|
61
|
+
logger.warn("Received message without type", message);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const messageHandlers: ((m: any) => void)[] | undefined =
|
|
66
|
+
this.handlers[message.type];
|
|
67
|
+
if (!messageHandlers) {
|
|
68
|
+
logger.warn("Received message without handler", message);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
messageHandlers.forEach((h) => h(message));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
37
76
|
export type SimpleReactPyClientProps = {
|
|
38
77
|
serverLocation?: LocationProps;
|
|
39
78
|
reconnectOptions?: ReconnectProps;
|
|
@@ -77,21 +116,15 @@ type ReconnectProps = {
|
|
|
77
116
|
intervalJitter?: number;
|
|
78
117
|
};
|
|
79
118
|
|
|
80
|
-
export class SimpleReactPyClient
|
|
81
|
-
|
|
82
|
-
|
|
119
|
+
export class SimpleReactPyClient
|
|
120
|
+
extends BaseReactPyClient
|
|
121
|
+
implements ReactPyClient
|
|
122
|
+
{
|
|
83
123
|
private readonly urls: ServerUrls;
|
|
84
|
-
private readonly handlers: {
|
|
85
|
-
[key in IncomingMessage["type"]]: ((message: any) => void)[];
|
|
86
|
-
};
|
|
87
124
|
private readonly socket: { current?: WebSocket };
|
|
88
125
|
|
|
89
126
|
constructor(props: SimpleReactPyClientProps) {
|
|
90
|
-
|
|
91
|
-
"connection-open": [],
|
|
92
|
-
"connection-close": [],
|
|
93
|
-
"layout-update": [],
|
|
94
|
-
};
|
|
127
|
+
super();
|
|
95
128
|
|
|
96
129
|
this.urls = getServerUrls(
|
|
97
130
|
props.serverLocation || {
|
|
@@ -101,66 +134,21 @@ export class SimpleReactPyClient implements ReactPyClient {
|
|
|
101
134
|
},
|
|
102
135
|
);
|
|
103
136
|
|
|
104
|
-
this.
|
|
105
|
-
|
|
106
|
-
};
|
|
107
|
-
this.resolveShouldClose = () => {
|
|
108
|
-
throw new Error("Could not stop client");
|
|
109
|
-
};
|
|
110
|
-
const shouldOpen = new Promise((r) => (this.resolveShouldOpen = r));
|
|
111
|
-
const shouldClose = new Promise((r) => (this.resolveShouldClose = r));
|
|
112
|
-
|
|
113
|
-
this.socket = startReconnectingWebSocket({
|
|
114
|
-
shouldOpen,
|
|
115
|
-
shouldClose,
|
|
137
|
+
this.socket = createReconnectingWebSocket({
|
|
138
|
+
readyPromise: this.ready,
|
|
116
139
|
url: this.urls.stream,
|
|
117
|
-
onOpen: () => this.handleIncoming({ type: "connection-open" }),
|
|
118
140
|
onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)),
|
|
119
|
-
onClose: () => this.handleIncoming({ type: "connection-close" }),
|
|
120
141
|
...props.reconnectOptions,
|
|
121
142
|
});
|
|
122
143
|
}
|
|
123
144
|
|
|
124
|
-
|
|
125
|
-
logger.log("starting client...");
|
|
126
|
-
this.resolveShouldOpen(undefined);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
stop(): void {
|
|
130
|
-
logger.log("stopping client...");
|
|
131
|
-
this.resolveShouldClose(undefined);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
onMessage<M extends IncomingMessage>(
|
|
135
|
-
type: M["type"],
|
|
136
|
-
handler: (message: M) => void,
|
|
137
|
-
): void {
|
|
138
|
-
this.handlers[type].push(handler);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
sendMessage(message: OutgoingMessage): void {
|
|
145
|
+
sendMessage(message: any): void {
|
|
142
146
|
this.socket.current?.send(JSON.stringify(message));
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
loadModule(moduleName: string): Promise<ReactPyModule> {
|
|
146
150
|
return import(`${this.urls.modules}/${moduleName}`);
|
|
147
151
|
}
|
|
148
|
-
|
|
149
|
-
private handleIncoming(message: IncomingMessage): void {
|
|
150
|
-
if (!message.type) {
|
|
151
|
-
logger.warn("Received message without type", message);
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const messageHandlers: ((m: any) => void)[] | undefined =
|
|
156
|
-
this.handlers[message.type];
|
|
157
|
-
if (!messageHandlers) {
|
|
158
|
-
logger.warn("Received message without handler", message);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
messageHandlers.forEach((h) => h(message));
|
|
163
|
-
}
|
|
164
152
|
}
|
|
165
153
|
|
|
166
154
|
type ServerUrls = {
|
|
@@ -182,14 +170,13 @@ function getServerUrls(props: LocationProps): ServerUrls {
|
|
|
182
170
|
return { base, modules, assets, stream };
|
|
183
171
|
}
|
|
184
172
|
|
|
185
|
-
function
|
|
173
|
+
function createReconnectingWebSocket(
|
|
186
174
|
props: {
|
|
187
|
-
shouldOpen: Promise<any>;
|
|
188
|
-
shouldClose: Promise<any>;
|
|
189
175
|
url: string;
|
|
190
|
-
|
|
176
|
+
readyPromise: Promise<void>;
|
|
177
|
+
onOpen?: () => void;
|
|
191
178
|
onMessage: (message: MessageEvent<any>) => void;
|
|
192
|
-
onClose
|
|
179
|
+
onClose?: () => void;
|
|
193
180
|
} & ReconnectProps,
|
|
194
181
|
) {
|
|
195
182
|
const {
|
|
@@ -202,7 +189,7 @@ function startReconnectingWebSocket(
|
|
|
202
189
|
const startInterval = 750;
|
|
203
190
|
let retries = 0;
|
|
204
191
|
let interval = startInterval;
|
|
205
|
-
|
|
192
|
+
const closed = false;
|
|
206
193
|
let everConnected = false;
|
|
207
194
|
const socket: { current?: WebSocket } = {};
|
|
208
195
|
|
|
@@ -216,7 +203,9 @@ function startReconnectingWebSocket(
|
|
|
216
203
|
logger.log("client connected");
|
|
217
204
|
interval = startInterval;
|
|
218
205
|
retries = 0;
|
|
219
|
-
props.onOpen
|
|
206
|
+
if (props.onOpen) {
|
|
207
|
+
props.onOpen();
|
|
208
|
+
}
|
|
220
209
|
};
|
|
221
210
|
socket.current.onmessage = props.onMessage;
|
|
222
211
|
socket.current.onclose = () => {
|
|
@@ -226,7 +215,9 @@ function startReconnectingWebSocket(
|
|
|
226
215
|
}
|
|
227
216
|
|
|
228
217
|
logger.log("client disconnected");
|
|
229
|
-
props.onClose
|
|
218
|
+
if (props.onClose) {
|
|
219
|
+
props.onClose();
|
|
220
|
+
}
|
|
230
221
|
|
|
231
222
|
if (retries >= maxRetries) {
|
|
232
223
|
return;
|
|
@@ -242,11 +233,7 @@ function startReconnectingWebSocket(
|
|
|
242
233
|
};
|
|
243
234
|
};
|
|
244
235
|
|
|
245
|
-
props.
|
|
246
|
-
props.shouldClose.then(() => {
|
|
247
|
-
closed = true;
|
|
248
|
-
socket.current?.close();
|
|
249
|
-
});
|
|
236
|
+
props.readyPromise.then(() => logger.log("starting client...")).then(connect);
|
|
250
237
|
|
|
251
238
|
return socket;
|
|
252
239
|
}
|
package/src/reactpy-vdom.tsx
CHANGED
|
@@ -155,8 +155,8 @@ function createEventHandler(
|
|
|
155
155
|
): [string, () => void] {
|
|
156
156
|
return [
|
|
157
157
|
name,
|
|
158
|
-
function () {
|
|
159
|
-
const data = Array.from(
|
|
158
|
+
function (...args: any[]) {
|
|
159
|
+
const data = Array.from(args).map((value) => {
|
|
160
160
|
if (!(typeof value === "object" && value.nativeEvent)) {
|
|
161
161
|
return value;
|
|
162
162
|
}
|