This commit is contained in:
Mike Sawka 2024-11-02 10:58:13 -07:00 committed by GitHub
parent 6ed812c8ea
commit eeda49bbde
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 797 additions and 98 deletions

View File

@ -81,9 +81,14 @@ func StyleTag(ctx context.Context, props map[string]any) any {
`, nil)
}
func BgItemTag(ctx context.Context, props map[string]any) any {
type BgItemProps struct {
Bg string
Label string
}
func BgItemTag(ctx context.Context, props BgItemProps) any {
clickFn := func() {
log.Printf("bg item clicked %q\n", props["bg"])
log.Printf("bg item clicked %q\n", props.Bg)
blockInfo, err := wshclient.BlockInfoCommand(GlobalVDomClient.RpcClient, GlobalVDomClient.RpcContext.BlockId, nil)
if err != nil {
log.Printf("error getting block info: %v\n", err)
@ -92,7 +97,7 @@ func BgItemTag(ctx context.Context, props map[string]any) any {
log.Printf("block info: tabid=%q\n", blockInfo.TabId)
err = wshclient.SetMetaCommand(GlobalVDomClient.RpcClient, wshrpc.CommandSetMetaData{
ORef: waveobj.ORef{OType: "tab", OID: blockInfo.TabId},
Meta: map[string]any{"bg": props["bg"]},
Meta: map[string]any{"bg": props.Bg},
}, nil)
if err != nil {
log.Printf("error setting meta: %v\n", err)
@ -100,8 +105,8 @@ func BgItemTag(ctx context.Context, props map[string]any) any {
// wshclient.SetMetaCommand(GlobalVDomClient.RpcClient)
}
params := map[string]any{
"bg": props["bg"],
"label": props["label"],
"bg": props.Bg,
"label": props.Label,
"clickHandler": clickFn,
}
return vdom.Bind(`
@ -143,6 +148,9 @@ func MakeVDom() *vdom.VDomElem {
<div>
<AllBgItemsTag/>
</div>
<div>
<img style="width: 100%; height: 100%; max-width: 300px; max-height: 300px; object-fit: contain;" src="vdom:///test.png"/>
</div>
</div>
`
elem := vdom.Bind(vdomStr, nil)
@ -169,6 +177,7 @@ func htmlRun(cmd *cobra.Command, args []string) error {
client.RegisterComponent("StyleTag", StyleTag)
client.RegisterComponent("BgItemTag", BgItemTag)
client.RegisterComponent("AllBgItemsTag", AllBgItemsTag)
client.RegisterFileHandler("/test.png", "~/Downloads/IMG_1939.png")
client.SetRootElem(MakeVDom())
err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: htmlCmdNewBlock})
if err != nil {

100
emain/emain-vdomhandler.ts Normal file
View File

@ -0,0 +1,100 @@
import { protocol } from "electron";
import { RpcApi } from "../frontend/app/store/wshclientapi";
import { base64ToArray } from "../frontend/util/util";
import { ElectronWshClient } from "./emain-wsh";
export function setupVdomUrlHandler() {
protocol.handle("vdom", async (request) => {
// Only handle GET requests for now
if (request.method !== "GET") {
return new Response(null, {
status: 405,
headers: {
"Content-Type": "text/plain",
},
});
}
const parts = request.url.split("/");
const uuid = parts[2];
// simple error checking for uuid
if (!uuid || uuid.length !== 36) {
return new Response(null, {
status: 400,
headers: {
"Content-Type": "text/plain",
},
});
}
const path = "/" + parts.slice(3).join("/");
// Convert Headers object to plain object
const headers: Record<string, string> = {};
for (const [key, value] of request.headers.entries()) {
headers[key] = value;
}
const data: VDomUrlRequestData = {
method: "GET",
url: path,
headers: headers,
};
try {
const respStream = RpcApi.VDomUrlRequestCommand(ElectronWshClient, data, {
route: `proc:${uuid}`,
});
// Get iterator for the stream
const iterator = respStream[Symbol.asyncIterator]();
// Get first chunk to extract headers and status
const firstChunk = await iterator.next();
if (firstChunk.done) {
throw new Error("No response received from backend");
}
const firstResp = firstChunk.value as VDomUrlRequestResponse;
const statusCode = firstResp.statuscode ?? 200;
const responseHeaders = firstResp.headers ?? {};
const stream = new ReadableStream({
async start(controller) {
try {
// Enqueue the body from the first chunk if it exists
if (firstResp.body) {
controller.enqueue(base64ToArray(firstResp.body));
}
// Process the rest of the stream
while (true) {
const chunk = await iterator.next();
if (chunk.done) break;
const resp = chunk.value as VDomUrlRequestResponse;
if (resp.body) {
controller.enqueue(base64ToArray(resp.body));
}
}
controller.close();
} catch (err) {
controller.error(err);
}
},
});
return new Response(stream, {
status: statusCode,
headers: responseHeaders,
});
} catch (err) {
console.error("VDOM URL handler error:", err);
return new Response(null, {
status: 500,
headers: {
"Content-Type": "text/plain",
},
});
}
});
}

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import * as electron from "electron";
import { setupVdomUrlHandler } from "emain/emain-vdomhandler";
import { FastAverageColor } from "fast-average-color";
import fs from "fs";
import * as child_process from "node:child_process";
@ -708,6 +709,7 @@ async function appMain() {
const ready = await getWaveSrvReady();
console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms");
await electronApp.whenReady();
setupVdomUrlHandler();
configureAuthKeyRequestInjection(electron.session.defaultSession);
const fullConfig = await services.FileService.GetFullConfig();
ensureHotSpareTab(fullConfig);
@ -721,7 +723,6 @@ async function appMain() {
console.log("error initializing wshrpc", e);
}
await configureAutoUpdater();
setGlobalIsStarting(false);
if (fullConfig?.settings?.["window:maxtabcachesize"] != null) {
setMaxTabCacheSize(fullConfig.settings["window:maxtabcachesize"]);

View File

@ -405,7 +405,7 @@
&.block-no-highlight,
&.block-preview {
.block-mask {
border: 2px solid rgba(255, 255, 255, 0.1);
border: 2px solid rgba(255, 255, 255, 0.1) !important;
}
}
}

View File

@ -252,6 +252,11 @@ class RpcApiType {
return client.wshRpcCall("vdomrender", data, opts);
}
// command "vdomurlrequest" [responsestream]
VDomUrlRequestCommand(client: WshClient, data: VDomUrlRequestData, opts?: RpcOpts): AsyncGenerator<VDomUrlRequestResponse, void, boolean> {
return client.wshRpcStream("vdomurlrequest", data, opts);
}
// command "waitforroute" [call]
WaitForRouteCommand(client: WshClient, data: CommandWaitForRouteData, opts?: RpcOpts): Promise<boolean> {
return client.wshRpcCall("waitforroute", data, opts);

View File

@ -46,24 +46,8 @@
min-height: 0;
overflow: hidden;
.term-htmlelem-focus {
height: 0;
width: 0;
input {
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
}
.term-htmlelem-content {
display: flex;
flex-direction: row;
width: 100%;
flex-grow: 1;
min-height: 0;
overflow: hidden;
.block-content {
padding: 0;
}
}

View File

@ -592,4 +592,12 @@ export class VDomModel {
}
return feUpdate;
}
getBackendRouteId(): string {
const fullRoute = globalStore.get(this.backendRoute);
if (fullRoute == null || !fullRoute.startsWith("proc:")) {
return null;
}
return fullRoute?.split(":")[1];
}
}

View File

@ -46,6 +46,33 @@ export function validateAndWrapCss(model: VDomModel, cssText: string, wrapperCla
if (node.type === "IdSelector") {
node.name = convertVDomId(model, node.name);
}
// Transform url(#id) references in filter and mask properties (svg)
if (node.type === "Declaration" && ["filter", "mask"].includes(node.property)) {
if (node.value && node.value.type === "Value" && "children" in node.value) {
const urlNode = node.value.children
.toArray()
.find(
(child: CssNode): child is CssNode & { value: string } =>
child && child.type === "Url" && typeof (child as any).value === "string"
);
if (urlNode && urlNode.value && urlNode.value.startsWith("#")) {
urlNode.value = "#" + convertVDomId(model, urlNode.value.substring(1));
}
}
}
// transform url(vdom:///foo.jpg) => url(vdom://blockId/foo.jpg)
if (node.type === "Url") {
const url = node.value;
if (url != null && url.startsWith("vdom://")) {
const absUrl = url.substring(7);
if (!absUrl.startsWith("/")) {
list.remove(item);
} else {
node.value = "vdom://" + model.blockId + url.substring(7);
}
}
}
},
});
const sanitizedCss = csstree.generate(ast);
@ -56,3 +83,56 @@ export function validateAndWrapCss(model: VDomModel, cssText: string, wrapperCla
return null;
}
}
function cssTransformStyleValue(model: VDomModel, property: string, value: string): string {
try {
const ast = csstree.parse(value, { context: "value" });
csstree.walk(ast, {
enter(node) {
// Transform url(#id) in filter/mask properties
if (node.type === "Url" && (property === "filter" || property === "mask")) {
if (node.value.startsWith("#")) {
node.value = `#${convertVDomId(model, node.value.substring(1))}`;
}
}
// Transform vdom:/// URLs
if (node.type === "Url" && node.value.startsWith("vdom:///")) {
const absUrl = node.value.substring(7);
if (absUrl.startsWith("/")) {
node.value = `vdom://${model.blockId}${absUrl}`;
}
}
},
});
return csstree.generate(ast);
} catch (error) {
console.error("Error processing style value:", error);
return value;
}
}
export function validateAndWrapReactStyle(model: VDomModel, style: Record<string, any>): Record<string, any> {
const sanitizedStyle: Record<string, any> = {};
let updated = false;
for (const [property, value] of Object.entries(style)) {
if (value == null || value === "") {
continue;
}
if (typeof value !== "string") {
sanitizedStyle[property] = value; // For non-string values, just copy as-is
continue;
}
if (value.includes("vdom://") || value.includes("url(#")) {
updated = true;
sanitizedStyle[property] = cssTransformStyleValue(model, property, value);
} else {
sanitizedStyle[property] = value;
}
}
if (!updated) {
return style;
}
return sanitizedStyle;
}

View File

@ -2,4 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
.view-vdom {
overflow: auto;
width: 100%;
min-height: 100%;
}

View File

@ -10,7 +10,12 @@ import * as jotai from "jotai";
import * as React from "react";
import { BlockNodeModel } from "@/app/block/blocktypes";
import { convertVDomId, getTextChildren, validateAndWrapCss } from "@/app/view/vdom/vdom-utils";
import {
convertVDomId,
getTextChildren,
validateAndWrapCss,
validateAndWrapReactStyle,
} from "@/app/view/vdom/vdom-utils";
import "./vdom.less";
const TextTag = "#text";
@ -68,6 +73,50 @@ const AllowedSimpleTags: { [tagName: string]: boolean } = {
code: true,
};
const AllowedSvgTags = {
// SVG tags
svg: true,
circle: true,
ellipse: true,
line: true,
path: true,
polygon: true,
polyline: true,
rect: true,
g: true,
text: true,
tspan: true,
textPath: true,
use: true,
defs: true,
linearGradient: true,
radialGradient: true,
stop: true,
clipPath: true,
mask: true,
pattern: true,
image: true,
marker: true,
symbol: true,
filter: true,
feBlend: true,
feColorMatrix: true,
feComponentTransfer: true,
feComposite: true,
feConvolveMatrix: true,
feDiffuseLighting: true,
feDisplacementMap: true,
feFlood: true,
feGaussianBlur: true,
feImage: true,
feMerge: true,
feMorphology: true,
feOffset: true,
feSpecularLighting: true,
feTile: true,
feTurbulence: true,
};
const IdAttributes = {
id: true,
for: true,
@ -81,6 +130,18 @@ const IdAttributes = {
list: true,
};
const SvgUrlIdAttributes = {
"clip-path": true,
mask: true,
filter: true,
fill: true,
stroke: true,
"marker-start": true,
"marker-mid": true,
"marker-end": true,
"text-decoration": true,
};
function convertVDomFunc(model: VDomModel, fnDecl: VDomFunc, compId: string, propName: string): (e: any) => void {
return (e: any) => {
if ((propName == "onKeyDown" || propName == "onKeyDownCapture") && fnDecl["#keys"]) {
@ -193,28 +254,62 @@ function convertProps(elem: VDomElem, model: VDomModel): [GenericPropsType, Set<
}
}
}
// fallthrough to set props[key] = val
val = validateAndWrapReactStyle(model, val);
props[key] = val;
continue;
}
if (IdAttributes[key]) {
props[key] = convertVDomId(model, val);
continue;
}
if (AllowedSvgTags[elem.tag]) {
if ((elem.tag == "use" && key == "href") || (elem.tag == "textPath" && key == "href")) {
if (val == null || !val.startsWith("#")) {
continue;
}
props[key] = convertVDomId(model, "#" + val.substring(1));
continue;
}
if (SvgUrlIdAttributes[key]) {
if (val == null || !val.startsWith("url(#") || !val.endsWith(")")) {
continue;
}
props[key] = "url(#" + convertVDomId(model, val.substring(4, val.length - 1)) + ")";
continue;
}
}
if (key == "src" && val != null && val.startsWith("vdom://")) {
// we're going to convert vdom:///foo.jpg to vdom://blockid/foo.jpg. if it doesn't start with "/" it is not valid
const vdomUrl = val.substring(7);
if (!vdomUrl.startsWith("/")) {
continue;
}
const backendRouteId = model.getBackendRouteId();
if (backendRouteId == null) {
continue;
}
props[key] = "vdom://" + backendRouteId + vdomUrl;
continue;
}
props[key] = val;
}
return [props, atomKeys];
}
function convertChildren(elem: VDomElem, model: VDomModel): (string | JSX.Element)[] {
let childrenComps: (string | JSX.Element)[] = [];
if (elem.children == null) {
return childrenComps;
if (elem.children == null || elem.children.length == 0) {
return null;
}
let childrenComps: (string | JSX.Element)[] = [];
for (let child of elem.children) {
if (child == null) {
continue;
}
childrenComps.push(convertElemToTag(child, model));
}
if (childrenComps.length == 0) {
return null;
}
return childrenComps;
}
@ -286,7 +381,7 @@ function VDomTag({ elem, model }: { elem: VDomElem; model: VDomModel }) {
if (elem.tag == StyleTagName) {
return <StyleTag elem={elem} model={model} />;
}
if (!AllowedSimpleTags[elem.tag]) {
if (!AllowedSimpleTags[elem.tag] && !AllowedSvgTags[elem.tag]) {
return <div>{"Invalid Tag <" + elem.tag + ">"}</div>;
}
let childrenComps = convertChildren(elem, model);
@ -345,7 +440,7 @@ function VDomView({ blockId, model }: VDomViewProps) {
model.viewRef = viewRef;
const vdomClass = "vdom-" + blockId;
return (
<div className={clsx("vdom-view", vdomClass)} ref={viewRef}>
<div className={clsx("view-vdom", vdomClass)} ref={viewRef}>
<VDomRoot model={model} />
</div>
);

View File

@ -782,6 +782,21 @@ declare global {
magnified?: boolean;
};
// wshrpc.VDomUrlRequestData
type VDomUrlRequestData = {
method: string;
url: string;
headers: {[key: string]: string};
body?: string;
};
// wshrpc.VDomUrlRequestResponse
type VDomUrlRequestResponse = {
statuscode?: number;
headers?: {[key: string]: string};
body?: string;
};
type WSCommandType = {
wscommand: string;
} & ( SetBlockTermSizeWSCommand | BlockInputWSCommand | WSRpcCommand );

2
go.mod
View File

@ -22,7 +22,7 @@ require (
github.com/skeema/knownhosts v1.3.0
github.com/spf13/cobra v1.8.1
github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b
github.com/wavetermdev/htmltoken v0.1.0
github.com/wavetermdev/htmltoken v0.2.0
golang.org/x/crypto v0.28.0
golang.org/x/sys v0.26.0
golang.org/x/term v0.25.0

4
go.sum
View File

@ -86,8 +86,8 @@ github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117 h1:XQpsQG5lqRJlx4m
github.com/ubuntu/decorate v0.0.0-20230125165522-2d5b0a9bb117/go.mod h1:mx0TjbqsaDD9DUT5gA1s3hw47U6RIbbIBfvGzR85K0g=
github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b h1:wFBKF5k5xbJQU8bYgcSoQ/ScvmYyq6KHUabAuVUjOWM=
github.com/ubuntu/gowsl v0.0.0-20240906163211-049fd49bd93b/go.mod h1:N1CYNinssZru+ikvYTgVbVeSi21thHUTCoJ9xMvWe+s=
github.com/wavetermdev/htmltoken v0.1.0 h1:RMdA9zTfnYa5jRC4RRG3XNoV5NOP8EDxpaVPjuVz//Q=
github.com/wavetermdev/htmltoken v0.1.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk=
github.com/wavetermdev/htmltoken v0.2.0 h1:sFVPPemlDv7/jg7n4Hx1AEF2m9MVAFjFpELWfhi/DlM=
github.com/wavetermdev/htmltoken v0.2.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk=
github.com/wavetermdev/ssh_config v0.0.0-20241027232332-ed124367682d h1:ArHaUBaiQWUqBzM2G/oLlm3Be0kwUMDt9vTNOWIfOd0=
github.com/wavetermdev/ssh_config v0.0.0-20241027232332-ed124367682d/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=

View File

@ -130,6 +130,10 @@ func TypeToTSType(t reflect.Type, tsTypesMap map[reflect.Type]string) (string, [
case reflect.Bool:
return "boolean", nil
case reflect.Slice, reflect.Array:
// special case for byte slice, marshals to base64 encoded string
if t.Elem().Kind() == reflect.Uint8 {
return "string", nil
}
elemType, subTypes := TypeToTSType(t.Elem(), tsTypesMap)
if elemType == "" {
return "", nil

View File

@ -58,6 +58,9 @@ func CompareAsFloat64(a, b any) bool {
// Convert various numeric types to float64 for comparison
func ToFloat64(val any) (float64, bool) {
if val == nil {
return 0, false
}
switch v := val.(type) {
case int:
return float64(v), true
@ -87,3 +90,57 @@ func ToFloat64(val any) (float64, bool) {
return 0, false
}
}
func ToInt64(val any) (int64, bool) {
if val == nil {
return 0, false
}
switch v := val.(type) {
case int:
return int64(v), true
case int8:
return int64(v), true
case int16:
return int64(v), true
case int32:
return int64(v), true
case int64:
return v, true
case uint:
return int64(v), true
case uint8:
return int64(v), true
case uint16:
return int64(v), true
case uint32:
return int64(v), true
case uint64:
return int64(v), true
case float32:
return int64(v), true
case float64:
return int64(v), true
default:
return 0, false
}
}
func ToInt(val any) (int, bool) {
i, ok := ToInt64(val)
if !ok {
return 0, false
}
return int(i), true
}
func ToStr(val any) (string, bool) {
if val == nil {
return "", false
}
switch v := val.(type) {
case string:
return v, true
default:
return "", false
}
}

View File

@ -25,8 +25,6 @@ type Hook struct {
Deps []any
}
type CFunc = func(ctx context.Context, props map[string]any) any
func (e *VDomElem) Key() string {
keyVal, ok := e.Props[KeyPropKey]
if !ok {

View File

@ -73,18 +73,6 @@ func finalizeStack(stack []*VDomElem) *VDomElem {
return rtnElem
}
func attrVal(attr htmltoken.Attribute) (any, error) {
// if !attr.IsJson {
// return attr.Val, nil
// }
var val any
err := json.Unmarshal([]byte(attr.Val), &val)
if err != nil {
return nil, fmt.Errorf("error parsing json attr %q: %v", attr.Key, err)
}
return val, nil
}
// returns value, isjson
func getAttrString(token htmltoken.Token, key string) string {
for _, attr := range token.Attr {
@ -96,6 +84,19 @@ func getAttrString(token htmltoken.Token, key string) string {
}
func attrToProp(attrVal string, isJson bool, params map[string]any) any {
if isJson {
var val any
err := json.Unmarshal([]byte(attrVal), &val)
if err != nil {
return nil
}
unmStrVal, ok := val.(string)
if !ok {
return val
}
attrVal = unmStrVal
// fallthrough using the json str val
}
if strings.HasPrefix(attrVal, Html_ParamPrefix) {
bindKey := attrVal[len(Html_ParamPrefix):]
bindVal, ok := params[bindKey]
@ -134,7 +135,7 @@ func tokenToElem(token htmltoken.Token, params map[string]any) *VDomElem {
if attr.Key == "" || attr.Val == "" {
continue
}
propVal := attrToProp(attr.Val, false, params)
propVal := attrToProp(attr.Val, attr.IsJson, params)
elem.Props[attr.Key] = propVal
}
return elem

View File

@ -32,7 +32,7 @@ type Atom struct {
type RootElem struct {
OuterCtx context.Context
Root *Component
CFuncs map[string]CFunc
CFuncs map[string]any
CompMap map[string]*Component // component waveid -> component
EffectWorkQueue []*EffectWorkElem
NeedsRenderMap map[string]bool
@ -63,7 +63,7 @@ func (r *RootElem) AddEffectWork(id string, effectIndex int) {
func MakeRoot() *RootElem {
return &RootElem{
Root: nil,
CFuncs: make(map[string]CFunc),
CFuncs: make(map[string]any),
CompMap: make(map[string]*Component),
Atoms: make(map[string]*Atom),
}
@ -112,8 +112,42 @@ func (r *RootElem) SetOuterCtx(ctx context.Context) {
r.OuterCtx = ctx
}
func (r *RootElem) RegisterComponent(name string, cfunc CFunc) {
func validateCFunc(cfunc any) error {
if cfunc == nil {
return fmt.Errorf("Component function cannot b nil")
}
rval := reflect.ValueOf(cfunc)
if rval.Kind() != reflect.Func {
return fmt.Errorf("Component function must be a function")
}
rtype := rval.Type()
if rtype.NumIn() != 2 {
return fmt.Errorf("Component function must take exactly 2 arguments")
}
if rtype.NumOut() != 1 {
return fmt.Errorf("Component function must return exactly 1 value")
}
// first arg must be context.Context
if rtype.In(0) != reflect.TypeOf((*context.Context)(nil)).Elem() {
return fmt.Errorf("Component function first argument must be context.Context")
}
// second can a map, or a struct, or ptr to struct (we'll reflect the value into it)
arg2Type := rtype.In(1)
if arg2Type.Kind() == reflect.Ptr {
arg2Type = arg2Type.Elem()
}
if arg2Type.Kind() != reflect.Map && arg2Type.Kind() != reflect.Struct {
return fmt.Errorf("Component function second argument must be a map or a struct")
}
return nil
}
func (r *RootElem) RegisterComponent(name string, cfunc any) error {
if err := validateCFunc(cfunc); err != nil {
return err
}
r.CFuncs[name] = cfunc
return nil
}
func (r *RootElem) Render(elem *VDomElem) {
@ -321,7 +355,19 @@ func getRenderContext(ctx context.Context) *VDomContextVal {
return v.(*VDomContextVal)
}
func (r *RootElem) renderComponent(cfunc CFunc, elem *VDomElem, comp **Component) {
func callCFunc(cfunc any, ctx context.Context, props map[string]any) any {
rval := reflect.ValueOf(cfunc)
arg2Type := rval.Type().In(1)
arg2Val := reflect.New(arg2Type)
utilfn.ReUnmarshal(arg2Val.Interface(), props)
rtnVal := rval.Call([]reflect.Value{reflect.ValueOf(ctx), arg2Val.Elem()})
if len(rtnVal) == 0 {
return nil
}
return rtnVal[0].Interface()
}
func (r *RootElem) renderComponent(cfunc any, elem *VDomElem, comp **Component) {
if (*comp).Children != nil {
for _, child := range (*comp).Children {
r.unmount(&child)
@ -334,7 +380,7 @@ func (r *RootElem) renderComponent(cfunc CFunc, elem *VDomElem, comp **Component
}
props[ChildrenPropKey] = elem.Children
ctx := r.makeRenderContext(*comp)
renderedElem := cfunc(ctx, props)
renderedElem := callCFunc(cfunc, ctx, props)
rtnElemArr := partToElems(renderedElem)
if len(rtnElemArr) == 0 {
r.unmount(&(*comp).Comp)

View File

@ -5,7 +5,10 @@ import (
"encoding/json"
"fmt"
"log"
"reflect"
"testing"
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
)
type renderContextKeyType struct{}
@ -118,3 +121,65 @@ func TestBind(t *testing.T) {
jsonBytes, _ = json.MarshalIndent(elem, "", " ")
log.Printf("%s\n", string(jsonBytes))
}
func TestJsonBind(t *testing.T) {
elem := Bind(`<div data1={5} data2={[1,2,3]} data3={{"a": 1}}/>`, nil)
if elem == nil {
t.Fatalf("elem is nil")
}
if elem.Tag != "div" {
t.Fatalf("elem.Tag: %s (expected 'div')\n", elem.Tag)
}
if elem.Props == nil || len(elem.Props) != 3 {
t.Fatalf("elem.Props: %v\n", elem.Props)
}
data1Val, ok := elem.Props["data1"]
if !ok {
t.Fatalf("data1 not found\n")
}
_, ok = data1Val.(float64)
if !ok {
t.Fatalf("data1: %T\n", data1Val)
}
data1Int, ok := utilfn.ToInt(data1Val)
if !ok || data1Int != 5 {
t.Fatalf("data1: %v\n", data1Val)
}
data2Val, ok := elem.Props["data2"]
if !ok {
t.Fatalf("data2 not found\n")
}
d2type := reflect.TypeOf(data2Val)
if d2type.Kind() != reflect.Slice {
t.Fatalf("data2: %T\n", data2Val)
}
data2Arr := data2Val.([]any)
if len(data2Arr) != 3 {
t.Fatalf("data2: %v\n", data2Val)
}
d2v2, ok := data2Arr[1].(float64)
if !ok || d2v2 != 2 {
t.Fatalf("data2: %v\n", data2Val)
}
data3Val, ok := elem.Props["data3"]
if !ok || data3Val == nil {
t.Fatalf("data3 not found\n")
}
d3type := reflect.TypeOf(data3Val)
if d3type.Kind() != reflect.Map {
t.Fatalf("data3: %T\n", data3Val)
}
data3Map := data3Val.(map[string]any)
if len(data3Map) != 1 {
t.Fatalf("data3: %v\n", data3Val)
}
d3v1, ok := data3Map["a"]
if !ok {
t.Fatalf("data3: %v\n", data3Val)
}
mval, ok := utilfn.ToInt(d3v1)
if !ok || mval != 1 {
t.Fatalf("data3: %v\n", data3Val)
}
log.Printf("elem: %v\n", elem)
}

View File

@ -0,0 +1,129 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package vdomclient
import (
"bytes"
"net/http"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
)
const maxChunkSize = 64 * 1024 // 64KB maximum chunk size
// StreamingResponseWriter implements http.ResponseWriter interface to stream response
// data through a channel rather than buffering it in memory. This is particularly
// useful for handling large responses like video streams or file downloads.
type StreamingResponseWriter struct {
header http.Header
statusCode int
respChan chan<- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]
headerSent bool
buffer *bytes.Buffer
}
func NewStreamingResponseWriter(respChan chan<- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]) *StreamingResponseWriter {
return &StreamingResponseWriter{
header: make(http.Header),
statusCode: http.StatusOK,
respChan: respChan,
headerSent: false,
buffer: bytes.NewBuffer(make([]byte, 0, maxChunkSize)),
}
}
func (w *StreamingResponseWriter) Header() http.Header {
return w.header
}
func (w *StreamingResponseWriter) WriteHeader(statusCode int) {
if w.headerSent {
return
}
w.statusCode = statusCode
w.headerSent = true
headers := make(map[string]string)
for key, values := range w.header {
if len(values) > 0 {
headers[key] = values[0]
}
}
w.respChan <- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]{
Response: wshrpc.VDomUrlRequestResponse{
StatusCode: w.statusCode,
Headers: headers,
},
}
}
// sendChunk sends a single chunk of exactly maxChunkSize (or less)
func (w *StreamingResponseWriter) sendChunk(data []byte) {
if len(data) == 0 {
return
}
chunk := make([]byte, len(data))
copy(chunk, data)
w.respChan <- wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse]{
Response: wshrpc.VDomUrlRequestResponse{
Body: chunk,
},
}
}
func (w *StreamingResponseWriter) Write(data []byte) (int, error) {
if !w.headerSent {
w.WriteHeader(http.StatusOK)
}
originalLen := len(data)
// If we already have data in the buffer
if w.buffer.Len() > 0 {
// Fill the buffer up to maxChunkSize
spaceInBuffer := maxChunkSize - w.buffer.Len()
if spaceInBuffer > 0 {
// How much of the new data can fit in the buffer
toBuffer := spaceInBuffer
if toBuffer > len(data) {
toBuffer = len(data)
}
w.buffer.Write(data[:toBuffer])
data = data[toBuffer:] // Advance data slice
}
// If buffer is full, send it
if w.buffer.Len() == maxChunkSize {
w.sendChunk(w.buffer.Bytes())
w.buffer.Reset()
}
}
// Send any full chunks from data
for len(data) >= maxChunkSize {
w.sendChunk(data[:maxChunkSize])
data = data[maxChunkSize:]
}
// Buffer any remaining data
if len(data) > 0 {
w.buffer.Write(data)
}
return originalLen, nil
}
func (w *StreamingResponseWriter) Close() error {
if !w.headerSent {
w.WriteHeader(http.StatusOK)
}
if w.buffer.Len() > 0 {
w.sendChunk(w.buffer.Bytes())
w.buffer.Reset()
}
return nil
}

View File

@ -4,15 +4,17 @@
package vdomclient
import (
"context"
"fmt"
"log"
"net/http"
"os"
"sync"
"time"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/wavetermdev/waveterm/pkg/vdom"
"github.com/wavetermdev/waveterm/pkg/wavebase"
"github.com/wavetermdev/waveterm/pkg/wps"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
@ -33,42 +35,8 @@ type Client struct {
DoneCh chan struct{}
Opts vdom.VDomBackendOpts
GlobalEventHandler func(client *Client, event vdom.VDomEvent)
}
type VDomServerImpl struct {
Client *Client
BlockId string
}
func (*VDomServerImpl) WshServerImpl() {}
func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) {
if feUpdate.Dispose {
log.Printf("got dispose from frontend\n")
impl.Client.doShutdown("got dispose from frontend")
return nil, nil
}
if impl.Client.GetIsDone() {
return nil, nil
}
// set atoms
for _, ss := range feUpdate.StateSync {
impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false)
}
// run events
for _, event := range feUpdate.Events {
if event.WaveId == "" {
if impl.Client.GlobalEventHandler != nil {
impl.Client.GlobalEventHandler(impl.Client, event)
}
} else {
impl.Client.Root.Event(event.WaveId, event.EventType, event.EventData)
}
}
if feUpdate.Resync {
return impl.Client.fullRender()
}
return impl.Client.incrementalRender()
UrlHandlerMux *mux.Router
OverrideUrlHandler http.Handler
}
func (c *Client) GetIsDone() bool {
@ -92,11 +60,16 @@ func (c *Client) SetGlobalEventHandler(handler func(client *Client, event vdom.V
c.GlobalEventHandler = handler
}
func (c *Client) SetOverrideUrlHandler(handler http.Handler) {
c.OverrideUrlHandler = handler
}
func MakeClient(opts *vdom.VDomBackendOpts) (*Client, error) {
client := &Client{
Lock: &sync.Mutex{},
Root: vdom.MakeRoot(),
DoneCh: make(chan struct{}),
UrlHandlerMux: mux.NewRouter(),
}
if opts != nil {
client.Opts = *opts
@ -197,8 +170,8 @@ func makeNullVDom() *vdom.VDomElem {
return &vdom.VDomElem{WaveId: uuid.New().String(), Tag: vdom.WaveNullTag}
}
func (c *Client) RegisterComponent(name string, cfunc vdom.CFunc) {
c.Root.RegisterComponent(name, cfunc)
func (c *Client) RegisterComponent(name string, cfunc any) error {
return c.Root.RegisterComponent(name, cfunc)
}
func (c *Client) fullRender() (*vdom.VDomBackendUpdate, error) {
@ -236,3 +209,14 @@ func (c *Client) incrementalRender() (*vdom.VDomBackendUpdate, error) {
StateSync: c.Root.GetStateSync(false),
}, nil
}
func (c *Client) RegisterUrlPathHandler(path string, handler http.Handler) {
c.UrlHandlerMux.Handle(path, handler)
}
func (c *Client) RegisterFileHandler(path string, fileName string) {
fileName = wavebase.ExpandHomeDirSafe(fileName)
c.UrlHandlerMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, fileName)
})
}

View File

@ -0,0 +1,95 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package vdomclient
import (
"bytes"
"context"
"fmt"
"log"
"net/http"
"github.com/wavetermdev/waveterm/pkg/vdom"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
)
type VDomServerImpl struct {
Client *Client
BlockId string
}
func (*VDomServerImpl) WshServerImpl() {}
func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error) {
if feUpdate.Dispose {
log.Printf("got dispose from frontend\n")
impl.Client.doShutdown("got dispose from frontend")
return nil, nil
}
if impl.Client.GetIsDone() {
return nil, nil
}
// set atoms
for _, ss := range feUpdate.StateSync {
impl.Client.Root.SetAtomVal(ss.Atom, ss.Value, false)
}
// run events
for _, event := range feUpdate.Events {
if event.WaveId == "" {
if impl.Client.GlobalEventHandler != nil {
impl.Client.GlobalEventHandler(impl.Client, event)
}
} else {
impl.Client.Root.Event(event.WaveId, event.EventType, event.EventData)
}
}
if feUpdate.Resync {
return impl.Client.fullRender()
}
return impl.Client.incrementalRender()
}
func (impl *VDomServerImpl) VDomUrlRequestCommand(ctx context.Context, data wshrpc.VDomUrlRequestData) chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse] {
log.Printf("VDomUrlRequestCommand: url=%q\n", data.URL)
respChan := make(chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse])
writer := NewStreamingResponseWriter(respChan)
go func() {
defer func() {
if r := recover(); r != nil {
// On panic, send 500 status code
writer.WriteHeader(http.StatusInternalServerError)
writer.Write([]byte(fmt.Sprintf("internal server error: %v", r)))
}
close(respChan)
}()
// Create an HTTP request from the RPC request data
var bodyReader *bytes.Reader
if data.Body != nil {
bodyReader = bytes.NewReader(data.Body)
} else {
bodyReader = bytes.NewReader([]byte{})
}
httpReq, err := http.NewRequest(data.Method, data.URL, bodyReader)
if err != nil {
writer.WriteHeader(http.StatusInternalServerError)
writer.Write([]byte(err.Error()))
return
}
for key, value := range data.Headers {
httpReq.Header.Set(key, value)
}
if impl.Client.OverrideUrlHandler != nil {
impl.Client.OverrideUrlHandler.ServeHTTP(writer, httpReq)
return
}
impl.Client.UrlHandlerMux.ServeHTTP(writer, httpReq)
}()
return respChan
}

View File

@ -303,6 +303,11 @@ func VDomRenderCommand(w *wshutil.WshRpc, data vdom.VDomFrontendUpdate, opts *ws
return resp, err
}
// command "vdomurlrequest", wshserver.VDomUrlRequestCommand
func VDomUrlRequestCommand(w *wshutil.WshRpc, data wshrpc.VDomUrlRequestData, opts *wshrpc.RpcOpts) chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse] {
return sendRpcRequestResponseStreamHelper[wshrpc.VDomUrlRequestResponse](w, "vdomurlrequest", data, opts)
}
// command "waitforroute", wshserver.WaitForRouteCommand
func WaitForRouteCommand(w *wshutil.WshRpc, data wshrpc.CommandWaitForRouteData, opts *wshrpc.RpcOpts) (bool, error) {
resp, err := sendRpcRequestCallHelper[bool](w, "waitforroute", data, opts)

View File

@ -81,6 +81,7 @@ const (
Command_VDomCreateContext = "vdomcreatecontext"
Command_VDomAsyncInitiation = "vdomasyncinitiation"
Command_VDomRender = "vdomrender"
Command_VDomUrlRequest = "vdomurlrequest"
)
type RespOrErrorUnion[T any] struct {
@ -157,6 +158,7 @@ type WshRpcInterface interface {
// proc
VDomRenderCommand(ctx context.Context, data vdom.VDomFrontendUpdate) (*vdom.VDomBackendUpdate, error)
VDomUrlRequestCommand(ctx context.Context, data VDomUrlRequestData) chan RespOrErrorUnion[VDomUrlRequestResponse]
}
// for frontend
@ -431,6 +433,19 @@ type WaveNotificationOptions struct {
Silent bool `json:"silent,omitempty"`
}
type VDomUrlRequestData struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Body []byte `json:"body,omitempty"`
}
type VDomUrlRequestResponse struct {
StatusCode int `json:"statuscode,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Body []byte `json:"body,omitempty"`
}
type WaveInfoData struct {
Version string `json:"version"`
BuildTime string `json:"buildtime"`