mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
VDom 11 (#1224)
* Fix VDom url caching -- use regular requests * new boilerplate to make writing apps easier * render-blocking global styles (to prevent render flash) * bug fixes and new functionality etc.
This commit is contained in:
parent
993d33585b
commit
f50ce9565c
@ -1,42 +0,0 @@
|
|||||||
.root {
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.background {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.background-inner {
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-item {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-item:hover {
|
|
||||||
background-color: var(--button-grey-hover-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-preview {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
margin-right: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 1px solid #777;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-label {
|
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
@ -1,193 +0,0 @@
|
|||||||
// Copyright 2024, Command Line Inc.
|
|
||||||
// SPDX-License-Identifier: Apache-2.0
|
|
||||||
|
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
_ "embed"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/wavetermdev/waveterm/pkg/vdom"
|
|
||||||
"github.com/wavetermdev/waveterm/pkg/vdom/vdomclient"
|
|
||||||
"github.com/wavetermdev/waveterm/pkg/waveobj"
|
|
||||||
"github.com/wavetermdev/waveterm/pkg/wshrpc"
|
|
||||||
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
|
|
||||||
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed htmlstyle.css
|
|
||||||
var htmlStyleCSS []byte
|
|
||||||
|
|
||||||
var htmlCmdNewBlock bool
|
|
||||||
var HtmlVDomClient *vdomclient.Client = vdomclient.MakeClient(&vdom.VDomBackendOpts{CloseOnCtrlC: true})
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
htmlCmd.Flags().BoolVarP(&htmlCmdNewBlock, "newblock", "n", false, "create a new block")
|
|
||||||
rootCmd.AddCommand(htmlCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var htmlCmd = &cobra.Command{
|
|
||||||
Use: "html",
|
|
||||||
Hidden: true,
|
|
||||||
Short: "launch demo vdom application",
|
|
||||||
RunE: htmlRun,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prop Types
|
|
||||||
type BgItemProps struct {
|
|
||||||
Bg string `json:"bg"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
OnClick func() `json:"onClick"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BgListProps struct {
|
|
||||||
Items []BgItem `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type BgItem struct {
|
|
||||||
Bg string `json:"bg"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Components
|
|
||||||
var Style = vdomclient.DefineComponent[struct{}](HtmlVDomClient, "Style",
|
|
||||||
func(ctx context.Context, _ struct{}) any {
|
|
||||||
return vdom.E("wave:style",
|
|
||||||
vdom.P("src", "vdom:///style.css"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
var BgItemTag = vdomclient.DefineComponent[BgItemProps](HtmlVDomClient, "BgItem",
|
|
||||||
func(ctx context.Context, props BgItemProps) any {
|
|
||||||
return vdom.E("div",
|
|
||||||
vdom.Class("bg-item"),
|
|
||||||
vdom.E("div",
|
|
||||||
vdom.Class("bg-preview"),
|
|
||||||
vdom.PStyle("background", props.Bg),
|
|
||||||
),
|
|
||||||
vdom.E("div",
|
|
||||||
vdom.Class("bg-label"),
|
|
||||||
props.Label,
|
|
||||||
),
|
|
||||||
vdom.P("onClick", props.OnClick),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
var BgList = vdomclient.DefineComponent[BgListProps](HtmlVDomClient, "BgList",
|
|
||||||
func(ctx context.Context, props BgListProps) any {
|
|
||||||
setBackground := func(bg string) func() {
|
|
||||||
return func() {
|
|
||||||
blockInfo, err := wshclient.BlockInfoCommand(HtmlVDomClient.RpcClient, HtmlVDomClient.RpcContext.BlockId, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("error getting block info: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = wshclient.SetMetaCommand(HtmlVDomClient.RpcClient, wshrpc.CommandSetMetaData{
|
|
||||||
ORef: waveobj.ORef{OType: "tab", OID: blockInfo.TabId},
|
|
||||||
Meta: map[string]any{"bg": bg},
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("error setting meta: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return vdom.E("div",
|
|
||||||
vdom.Class("background"),
|
|
||||||
vdom.E("div",
|
|
||||||
vdom.Class("background-inner"),
|
|
||||||
vdom.ForEach(props.Items, func(item BgItem) any {
|
|
||||||
return BgItemTag(BgItemProps{
|
|
||||||
Bg: item.Bg,
|
|
||||||
Label: item.Label,
|
|
||||||
OnClick: setBackground(item.Bg),
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
var App = vdomclient.DefineComponent[struct{}](HtmlVDomClient, "App",
|
|
||||||
func(ctx context.Context, _ struct{}) any {
|
|
||||||
inputText, setInputText := vdom.UseState(ctx, "start")
|
|
||||||
|
|
||||||
bgItems := []BgItem{
|
|
||||||
{Bg: "", Label: "default"},
|
|
||||||
{Bg: "#ff0000", Label: "red"},
|
|
||||||
{Bg: "#00ff00", Label: "green"},
|
|
||||||
{Bg: "#0000ff", Label: "blue"},
|
|
||||||
}
|
|
||||||
|
|
||||||
return vdom.E("div",
|
|
||||||
vdom.Class("root"),
|
|
||||||
Style(struct{}{}),
|
|
||||||
vdom.E("h1", nil, "Set Background"),
|
|
||||||
vdom.E("div", nil,
|
|
||||||
vdom.E("wave:markdown",
|
|
||||||
vdom.P("text", "*quick vdom application to set background colors*"),
|
|
||||||
vdom.P("scrollable", false),
|
|
||||||
vdom.P("rehype", false),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
vdom.E("div", nil,
|
|
||||||
BgList(BgListProps{Items: bgItems}),
|
|
||||||
),
|
|
||||||
vdom.E("div", nil,
|
|
||||||
vdom.E("img",
|
|
||||||
vdom.PStyle("width", "100%"),
|
|
||||||
vdom.PStyle("height", "100%"),
|
|
||||||
vdom.PStyle("maxWidth", "300px"),
|
|
||||||
vdom.PStyle("maxHeight", "300px"),
|
|
||||||
vdom.PStyle("objectFit", "contain"),
|
|
||||||
vdom.P("src", "vdom:///test.png"),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
vdom.E("div", nil,
|
|
||||||
vdom.E("input",
|
|
||||||
vdom.P("type", "text"),
|
|
||||||
vdom.P("value", inputText),
|
|
||||||
vdom.P("onChange", func(e vdom.VDomEvent) {
|
|
||||||
setInputText(e.TargetValue)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
vdom.E("div", nil, "text ", inputText),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
func htmlRun(cmd *cobra.Command, args []string) error {
|
|
||||||
WriteStderr("running wsh html %q\n", RpcContext.BlockId)
|
|
||||||
client := HtmlVDomClient
|
|
||||||
err := client.Connect()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
client.SetRootElem(App(struct{}{}))
|
|
||||||
client.RegisterFileHandler("/style.css", vdomclient.FileHandlerOption{
|
|
||||||
Data: htmlStyleCSS,
|
|
||||||
MimeType: "text/css",
|
|
||||||
})
|
|
||||||
client.RegisterFileHandler("/test.png", vdomclient.FileHandlerOption{
|
|
||||||
FilePath: "~/Downloads/IMG_1939.png",
|
|
||||||
})
|
|
||||||
|
|
||||||
err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: htmlCmdNewBlock})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
<-client.DoneCh
|
|
||||||
wshutil.DoShutdown("vdom closed by FE", 0, true)
|
|
||||||
}()
|
|
||||||
|
|
||||||
<-client.DoneCh
|
|
||||||
return nil
|
|
||||||
}
|
|
@ -1,112 +0,0 @@
|
|||||||
import { protocol } from "electron";
|
|
||||||
import { RpcApi } from "../frontend/app/store/wshclientapi";
|
|
||||||
import { base64ToArray } from "../frontend/util/util";
|
|
||||||
import { ElectronWshClient } from "./emain-wsh";
|
|
||||||
|
|
||||||
export function registerVDomProtocol() {
|
|
||||||
protocol.registerSchemesAsPrivileged([
|
|
||||||
{
|
|
||||||
scheme: "vdom",
|
|
||||||
privileges: {
|
|
||||||
standard: true,
|
|
||||||
supportFetchAPI: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
@ -2,7 +2,6 @@
|
|||||||
// SPDX-License-Identifier: Apache-2.0
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
import * as electron from "electron";
|
import * as electron from "electron";
|
||||||
import { registerVDomProtocol, setupVdomUrlHandler } from "emain/emain-vdomhandler";
|
|
||||||
import { FastAverageColor } from "fast-average-color";
|
import { FastAverageColor } from "fast-average-color";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import * as child_process from "node:child_process";
|
import * as child_process from "node:child_process";
|
||||||
@ -700,7 +699,6 @@ async function appMain() {
|
|||||||
electronApp.quit();
|
electronApp.quit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
registerVDomProtocol();
|
|
||||||
makeAppMenu();
|
makeAppMenu();
|
||||||
try {
|
try {
|
||||||
await runWaveSrv(handleWSEvent);
|
await runWaveSrv(handleWSEvent);
|
||||||
@ -710,7 +708,6 @@ async function appMain() {
|
|||||||
const ready = await getWaveSrvReady();
|
const ready = await getWaveSrvReady();
|
||||||
console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms");
|
console.log("wavesrv ready signal received", ready, Date.now() - startTs, "ms");
|
||||||
await electronApp.whenReady();
|
await electronApp.whenReady();
|
||||||
setupVdomUrlHandler();
|
|
||||||
configureAuthKeyRequestInjection(electron.session.defaultSession);
|
configureAuthKeyRequestInjection(electron.session.defaultSession);
|
||||||
const fullConfig = await services.FileService.GetFullConfig();
|
const fullConfig = await services.FileService.GetFullConfig();
|
||||||
ensureHotSpareTab(fullConfig);
|
ensureHotSpareTab(fullConfig);
|
||||||
|
@ -10,6 +10,7 @@ import { RpcApi } from "@/app/store/wshclientapi";
|
|||||||
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
|
import { makeFeBlockRouteId } from "@/app/store/wshrouter";
|
||||||
import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
|
import { DefaultRouter, TabRpcClient } from "@/app/store/wshrpcutil";
|
||||||
import { applyCanvasOp, mergeBackendUpdates, restoreVDomElems } from "@/app/view/vdom/vdom-utils";
|
import { applyCanvasOp, mergeBackendUpdates, restoreVDomElems } from "@/app/view/vdom/vdom-utils";
|
||||||
|
import { getWebServerEndpoint } from "@/util/endpoints";
|
||||||
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
|
import { adaptFromReactOrNativeKeyEvent, checkKeyPressed } from "@/util/keyutil";
|
||||||
import debug from "debug";
|
import debug from "debug";
|
||||||
import * as jotai from "jotai";
|
import * as jotai from "jotai";
|
||||||
@ -215,6 +216,33 @@ export class VDomModel {
|
|||||||
return blockData?.meta?.["vdom:route"];
|
return blockData?.meta?.["vdom:route"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
transformVDomUrl(url: string): string {
|
||||||
|
if (url == null || url == "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!url.startsWith("vdom://")) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
const absUrl = url.substring(7);
|
||||||
|
return this.makeVDomUrl(absUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
makeVDomUrl(path: string): string {
|
||||||
|
if (path == null || path == "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!path.startsWith("/")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const backendRouteId = this.getBackendRouteId();
|
||||||
|
if (backendRouteId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const wsEndpoint = getWebServerEndpoint();
|
||||||
|
const fullUrl = wsEndpoint + "/vdom/" + backendRouteId + path;
|
||||||
|
return fullUrl;
|
||||||
|
}
|
||||||
|
|
||||||
keyDownHandler(e: WaveKeyboardEvent): boolean {
|
keyDownHandler(e: WaveKeyboardEvent): boolean {
|
||||||
if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, "Ctrl:c")) {
|
if (this.backendOpts?.closeonctrlc && checkKeyPressed(e, "Ctrl:c")) {
|
||||||
this.shouldDispose = true;
|
this.shouldDispose = true;
|
||||||
|
@ -61,16 +61,13 @@ export function validateAndWrapCss(model: VDomModel, cssText: string, wrapperCla
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// transform url(vdom:///foo.jpg) => url(vdom://blockId/foo.jpg)
|
// transform url(vdom:///foo.jpg)
|
||||||
if (node.type === "Url") {
|
if (node.type === "Url" && node.value != null && node.value.startsWith("vdom://")) {
|
||||||
const url = node.value;
|
const newUrl = model.transformVDomUrl(node.value);
|
||||||
if (url != null && url.startsWith("vdom://")) {
|
if (newUrl == null) {
|
||||||
const absUrl = url.substring(7);
|
|
||||||
if (!absUrl.startsWith("/")) {
|
|
||||||
list.remove(item);
|
list.remove(item);
|
||||||
} else {
|
} else {
|
||||||
node.value = "vdom://" + model.blockId + url.substring(7);
|
node.value = newUrl;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -88,19 +85,20 @@ function cssTransformStyleValue(model: VDomModel, property: string, value: strin
|
|||||||
try {
|
try {
|
||||||
const ast = csstree.parse(value, { context: "value" });
|
const ast = csstree.parse(value, { context: "value" });
|
||||||
csstree.walk(ast, {
|
csstree.walk(ast, {
|
||||||
enter(node) {
|
enter(node: CssNode, item: ListItem<CssNode>, list: List<CssNode>) {
|
||||||
// Transform url(#id) in filter/mask properties
|
// Transform url(#id) in filter/mask properties
|
||||||
if (node.type === "Url" && (property === "filter" || property === "mask")) {
|
if (node.type === "Url" && (property === "filter" || property === "mask")) {
|
||||||
if (node.value.startsWith("#")) {
|
if (node.value.startsWith("#")) {
|
||||||
node.value = `#${convertVDomId(model, node.value.substring(1))}`;
|
node.value = `#${convertVDomId(model, node.value.substring(1))}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// transform vdom:// urls
|
||||||
// Transform vdom:/// URLs
|
if (node.type === "Url" && node.value != null && node.value.startsWith("vdom://")) {
|
||||||
if (node.type === "Url" && node.value.startsWith("vdom:///")) {
|
const newUrl = model.transformVDomUrl(node.value);
|
||||||
const absUrl = node.value.substring(7);
|
if (newUrl == null) {
|
||||||
if (absUrl.startsWith("/")) {
|
list.remove(item);
|
||||||
node.value = `vdom://${model.blockId}${absUrl}`;
|
} else {
|
||||||
|
node.value = newUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -282,16 +282,12 @@ function convertProps(elem: VDomElem, model: VDomModel): [GenericPropsType, Set<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (key == "src" && val != null && val.startsWith("vdom://")) {
|
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
|
// transform vdom:// urls
|
||||||
const vdomUrl = val.substring(7);
|
const newUrl = model.transformVDomUrl(val);
|
||||||
if (!vdomUrl.startsWith("/")) {
|
if (newUrl == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const backendRouteId = model.getBackendRouteId();
|
props[key] = newUrl;
|
||||||
if (backendRouteId == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
props[key] = "vdom://" + backendRouteId + vdomUrl;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
props[key] = val;
|
props[key] = val;
|
||||||
@ -375,7 +371,7 @@ function StyleTag({ elem, model }: { elem: VDomElem; model: VDomModel }) {
|
|||||||
return <style>{sanitizedCss}</style>;
|
return <style>{sanitizedCss}</style>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function WaveStyle({ src, model }: { src: string; model: VDomModel }) {
|
function WaveStyle({ src, model, onMount }: { src: string; model: VDomModel; onMount?: () => void }) {
|
||||||
const [styleContent, setStyleContent] = React.useState<string | null>(null);
|
const [styleContent, setStyleContent] = React.useState<string | null>(null);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
async function fetchAndSanitizeCss() {
|
async function fetchAndSanitizeCss() {
|
||||||
@ -391,14 +387,22 @@ function WaveStyle({ src, model }: { src: string; model: VDomModel }) {
|
|||||||
if (sanitizedCss) {
|
if (sanitizedCss) {
|
||||||
setStyleContent(sanitizedCss);
|
setStyleContent(sanitizedCss);
|
||||||
} else {
|
} else {
|
||||||
|
onMount?.();
|
||||||
console.error("Failed to sanitize CSS");
|
console.error("Failed to sanitize CSS");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching CSS:", error);
|
console.error("Error fetching CSS:", error);
|
||||||
|
onMount?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchAndSanitizeCss();
|
fetchAndSanitizeCss();
|
||||||
}, [src, model]);
|
}, [src, model]);
|
||||||
|
// Trigger onMount after styleContent has been set and mounted
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (styleContent) {
|
||||||
|
onMount?.();
|
||||||
|
}
|
||||||
|
}, [styleContent, onMount]);
|
||||||
if (!styleContent) {
|
if (!styleContent) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -481,13 +485,29 @@ type VDomViewProps = {
|
|||||||
blockId: string;
|
blockId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function VDomInnerView({ blockId, model }: VDomViewProps) {
|
||||||
|
let [styleMounted, setStyleMounted] = React.useState(!model.backendOpts?.globalstyles);
|
||||||
|
const handleStylesMounted = () => {
|
||||||
|
setStyleMounted(true);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{model.backendOpts?.globalstyles ? (
|
||||||
|
<WaveStyle src={model.makeVDomUrl("/wave/global.css")} model={model} onMount={handleStylesMounted} />
|
||||||
|
) : null}
|
||||||
|
{styleMounted ? <VDomRoot model={model} /> : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function VDomView({ blockId, model }: VDomViewProps) {
|
function VDomView({ blockId, model }: VDomViewProps) {
|
||||||
let viewRef = React.useRef(null);
|
let viewRef = React.useRef(null);
|
||||||
|
let contextActive = jotai.useAtomValue(model.contextActive);
|
||||||
model.viewRef = viewRef;
|
model.viewRef = viewRef;
|
||||||
const vdomClass = "vdom-" + blockId;
|
const vdomClass = "vdom-" + blockId;
|
||||||
return (
|
return (
|
||||||
<div className={clsx("view-vdom", vdomClass)} ref={viewRef}>
|
<div className={clsx("view-vdom", vdomClass)} ref={viewRef}>
|
||||||
<VDomRoot model={model} />
|
{contextActive ? <VDomInnerView blockId={blockId} model={model} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
1
frontend/types/gotypes.d.ts
vendored
1
frontend/types/gotypes.d.ts
vendored
@ -642,6 +642,7 @@ declare global {
|
|||||||
type VDomBackendOpts = {
|
type VDomBackendOpts = {
|
||||||
closeonctrlc?: boolean;
|
closeonctrlc?: boolean;
|
||||||
globalkeyboardevents?: boolean;
|
globalkeyboardevents?: boolean;
|
||||||
|
globalstyles?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// vdom.VDomBackendUpdate
|
// vdom.VDomBackendUpdate
|
||||||
|
@ -197,6 +197,15 @@ func ForEach[T any](items []T, fn func(T) any) []any {
|
|||||||
return elems
|
return elems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ForEachIdx[T any](items []T, fn func(T, int) any) []any {
|
||||||
|
var elems []any
|
||||||
|
for idx, item := range items {
|
||||||
|
fnResult := fn(item, idx)
|
||||||
|
elems = append(elems, fnResult)
|
||||||
|
}
|
||||||
|
return elems
|
||||||
|
}
|
||||||
|
|
||||||
func Props(props any) map[string]any {
|
func Props(props any) map[string]any {
|
||||||
m, err := utilfn.StructToMap(props)
|
m, err := utilfn.StructToMap(props)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -209,6 +218,10 @@ func PStyle(styleAttr string, propVal any) any {
|
|||||||
return styleAttrWrapper{StyleAttr: styleAttr, Val: propVal}
|
return styleAttrWrapper{StyleAttr: styleAttr, Val: propVal}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Fragment(parts ...any) any {
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
func P(propName string, propVal any) any {
|
func P(propName string, propVal any) any {
|
||||||
if propVal == nil {
|
if propVal == nil {
|
||||||
return map[string]any{propName: nil}
|
return map[string]any{propName: nil}
|
||||||
|
@ -140,13 +140,19 @@ func validateCFunc(cfunc any) error {
|
|||||||
if rtype.In(0) != reflect.TypeOf((*context.Context)(nil)).Elem() {
|
if rtype.In(0) != reflect.TypeOf((*context.Context)(nil)).Elem() {
|
||||||
return fmt.Errorf("Component function first argument must be context.Context")
|
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)
|
// second can a map[string]any, or a struct, or ptr to struct (we'll reflect the value into it)
|
||||||
arg2Type := rtype.In(1)
|
arg2Type := rtype.In(1)
|
||||||
if arg2Type.Kind() == reflect.Ptr {
|
if arg2Type.Kind() == reflect.Ptr {
|
||||||
arg2Type = arg2Type.Elem()
|
arg2Type = arg2Type.Elem()
|
||||||
}
|
}
|
||||||
if arg2Type.Kind() != reflect.Map && arg2Type.Kind() != reflect.Struct {
|
if arg2Type.Kind() == reflect.Map {
|
||||||
return fmt.Errorf("Component function second argument must be a map or a struct")
|
if arg2Type.Key().Kind() != reflect.String ||
|
||||||
|
!(arg2Type.Elem().Kind() == reflect.Interface && arg2Type.Elem().NumMethod() == 0) {
|
||||||
|
return fmt.Errorf("Map argument must be map[string]any")
|
||||||
|
}
|
||||||
|
} else if arg2Type.Kind() != reflect.Struct &&
|
||||||
|
!(arg2Type.Kind() == reflect.Interface && arg2Type.NumMethod() == 0) {
|
||||||
|
return fmt.Errorf("Component function second argument must be map[string]any, struct, or any")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -374,7 +380,13 @@ func getRenderContext(ctx context.Context) *VDomContextVal {
|
|||||||
func callCFunc(cfunc any, ctx context.Context, props map[string]any) any {
|
func callCFunc(cfunc any, ctx context.Context, props map[string]any) any {
|
||||||
rval := reflect.ValueOf(cfunc)
|
rval := reflect.ValueOf(cfunc)
|
||||||
arg2Type := rval.Type().In(1)
|
arg2Type := rval.Type().In(1)
|
||||||
arg2Val := reflect.New(arg2Type)
|
|
||||||
|
var arg2Val reflect.Value
|
||||||
|
if arg2Type.Kind() == reflect.Interface && arg2Type.NumMethod() == 0 {
|
||||||
|
// For any/interface{}, pass nil properly
|
||||||
|
arg2Val = reflect.New(arg2Type)
|
||||||
|
} else {
|
||||||
|
arg2Val = reflect.New(arg2Type)
|
||||||
// if arg2 is a map, just pass props
|
// if arg2 is a map, just pass props
|
||||||
if arg2Type.Kind() == reflect.Map {
|
if arg2Type.Kind() == reflect.Map {
|
||||||
arg2Val.Elem().Set(reflect.ValueOf(props))
|
arg2Val.Elem().Set(reflect.ValueOf(props))
|
||||||
@ -384,6 +396,7 @@ func callCFunc(cfunc any, ctx context.Context, props map[string]any) any {
|
|||||||
fmt.Printf("error unmarshalling props: %v\n", err)
|
fmt.Printf("error unmarshalling props: %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
rtnVal := rval.Call([]reflect.Value{reflect.ValueOf(ctx), arg2Val.Elem()})
|
rtnVal := rval.Call([]reflect.Value{reflect.ValueOf(ctx), arg2Val.Elem()})
|
||||||
if len(rtnVal) == 0 {
|
if len(rtnVal) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
@ -177,6 +177,7 @@ type VDomRefUpdate struct {
|
|||||||
type VDomBackendOpts struct {
|
type VDomBackendOpts struct {
|
||||||
CloseOnCtrlC bool `json:"closeonctrlc,omitempty"`
|
CloseOnCtrlC bool `json:"closeonctrlc,omitempty"`
|
||||||
GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"`
|
GlobalKeyboardEvents bool `json:"globalkeyboardevents,omitempty"`
|
||||||
|
GlobalStyles bool `json:"globalstyles,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VDomRenderUpdate struct {
|
type VDomRenderUpdate struct {
|
||||||
|
@ -5,12 +5,14 @@ package vdomclient
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
@ -25,8 +27,17 @@ import (
|
|||||||
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type AppOpts struct {
|
||||||
|
CloseOnCtrlC bool
|
||||||
|
GlobalKeyboardEvents bool
|
||||||
|
GlobalStyles []byte
|
||||||
|
RootComponentName string // defaults to "App"
|
||||||
|
NewBlockFlag string // defaults to "n" (set to "-" to disable)
|
||||||
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Lock *sync.Mutex
|
Lock *sync.Mutex
|
||||||
|
AppOpts AppOpts
|
||||||
Root *vdom.RootElem
|
Root *vdom.RootElem
|
||||||
RootElem *vdom.VDomElem
|
RootElem *vdom.VDomElem
|
||||||
RpcClient *wshutil.WshRpc
|
RpcClient *wshutil.WshRpc
|
||||||
@ -39,8 +50,11 @@ type Client struct {
|
|||||||
DoneCh chan struct{}
|
DoneCh chan struct{}
|
||||||
Opts vdom.VDomBackendOpts
|
Opts vdom.VDomBackendOpts
|
||||||
GlobalEventHandler func(client *Client, event vdom.VDomEvent)
|
GlobalEventHandler func(client *Client, event vdom.VDomEvent)
|
||||||
|
GlobalStylesOption *FileHandlerOption
|
||||||
UrlHandlerMux *mux.Router
|
UrlHandlerMux *mux.Router
|
||||||
OverrideUrlHandler http.Handler
|
OverrideUrlHandler http.Handler
|
||||||
|
NewBlockFlag bool
|
||||||
|
SetupFn func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetIsDone() bool {
|
func (c *Client) GetIsDone() bool {
|
||||||
@ -68,19 +82,70 @@ func (c *Client) SetOverrideUrlHandler(handler http.Handler) {
|
|||||||
c.OverrideUrlHandler = handler
|
c.OverrideUrlHandler = handler
|
||||||
}
|
}
|
||||||
|
|
||||||
func MakeClient(opts *vdom.VDomBackendOpts) *Client {
|
func MakeClient(appOpts AppOpts) *Client {
|
||||||
|
if appOpts.RootComponentName == "" {
|
||||||
|
appOpts.RootComponentName = "App"
|
||||||
|
}
|
||||||
|
if appOpts.NewBlockFlag == "" {
|
||||||
|
appOpts.NewBlockFlag = "n"
|
||||||
|
}
|
||||||
client := &Client{
|
client := &Client{
|
||||||
Lock: &sync.Mutex{},
|
Lock: &sync.Mutex{},
|
||||||
|
AppOpts: appOpts,
|
||||||
Root: vdom.MakeRoot(),
|
Root: vdom.MakeRoot(),
|
||||||
DoneCh: make(chan struct{}),
|
DoneCh: make(chan struct{}),
|
||||||
UrlHandlerMux: mux.NewRouter(),
|
UrlHandlerMux: mux.NewRouter(),
|
||||||
|
Opts: vdom.VDomBackendOpts{
|
||||||
|
CloseOnCtrlC: appOpts.CloseOnCtrlC,
|
||||||
|
GlobalKeyboardEvents: appOpts.GlobalKeyboardEvents,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if opts != nil {
|
if len(appOpts.GlobalStyles) > 0 {
|
||||||
client.Opts = *opts
|
client.Opts.GlobalStyles = true
|
||||||
|
client.GlobalStylesOption = &FileHandlerOption{Data: appOpts.GlobalStyles, MimeType: "text/css"}
|
||||||
}
|
}
|
||||||
|
client.SetRootElem(vdom.E(appOpts.RootComponentName))
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (client *Client) runMainE() error {
|
||||||
|
if client.SetupFn != nil {
|
||||||
|
client.SetupFn()
|
||||||
|
}
|
||||||
|
err := client.Connect()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = client.CreateVDomContext(&vdom.VDomTarget{NewBlock: client.NewBlockFlag})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
<-client.DoneCh
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) AddSetupFn(fn func()) {
|
||||||
|
client.SetupFn = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) RegisterDefaultFlags() {
|
||||||
|
if client.AppOpts.NewBlockFlag != "-" {
|
||||||
|
flag.BoolVar(&client.NewBlockFlag, client.AppOpts.NewBlockFlag, false, "new block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (client *Client) RunMain() {
|
||||||
|
if !flag.Parsed() {
|
||||||
|
client.RegisterDefaultFlags()
|
||||||
|
flag.Parse()
|
||||||
|
}
|
||||||
|
err := client.runMainE()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (client *Client) Connect() error {
|
func (client *Client) Connect() error {
|
||||||
jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName)
|
jwtToken := os.Getenv(wshutil.WaveJwtTokenVarName)
|
||||||
if jwtToken == "" {
|
if jwtToken == "" {
|
||||||
@ -185,7 +250,10 @@ func DefineComponent[P any](client *Client, name string, renderFn func(ctx conte
|
|||||||
if !unicode.IsUpper(rune(name[0])) {
|
if !unicode.IsUpper(rune(name[0])) {
|
||||||
panic("Component name must start with an uppercase letter")
|
panic("Component name must start with an uppercase letter")
|
||||||
}
|
}
|
||||||
client.RegisterComponent(name, renderFn)
|
err := client.RegisterComponent(name, renderFn)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
return func(props P) *vdom.VDomElem {
|
return func(props P) *vdom.VDomElem {
|
||||||
return vdom.E(name, vdom.Props(props))
|
return vdom.E(name, vdom.Props(props))
|
||||||
}
|
}
|
||||||
@ -244,6 +312,7 @@ type FileHandlerOption struct {
|
|||||||
Reader io.Reader // optional reader for content
|
Reader io.Reader // optional reader for content
|
||||||
File fs.File // optional embedded or opened file
|
File fs.File // optional embedded or opened file
|
||||||
MimeType string // optional mime type
|
MimeType string // optional mime type
|
||||||
|
ETag string // optional ETag (if set, resource may be cached)
|
||||||
}
|
}
|
||||||
|
|
||||||
func determineMimeType(option FileHandlerOption) (string, []byte) {
|
func determineMimeType(option FileHandlerOption) (string, []byte) {
|
||||||
@ -299,50 +368,92 @@ func determineMimeType(option FileHandlerOption) (string, []byte) {
|
|||||||
return "application/octet-stream", nil
|
return "application/octet-stream", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) RegisterFileHandler(path string, option FileHandlerOption) {
|
// ServeFileOption handles serving content based on the provided FileHandlerOption
|
||||||
c.UrlHandlerMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
func ServeFileOption(w http.ResponseWriter, r *http.Request, option FileHandlerOption) error {
|
||||||
// Determine MIME type and get buffered data if needed
|
// Determine MIME type and get buffered data if needed
|
||||||
contentType, bufferedData := determineMimeType(option)
|
contentType, bufferedData := determineMimeType(option)
|
||||||
w.Header().Set("Content-Type", contentType)
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
// Handle ETag
|
||||||
|
if option.ETag != "" {
|
||||||
|
w.Header().Set("ETag", option.ETag)
|
||||||
|
|
||||||
if option.FilePath != "" {
|
// Check If-None-Match header
|
||||||
// Serve file from path
|
if inm := r.Header.Get("If-None-Match"); inm != "" {
|
||||||
|
// Strip W/ prefix and quotes if present
|
||||||
|
inm = strings.Trim(inm, `"`)
|
||||||
|
inm = strings.TrimPrefix(inm, "W/")
|
||||||
|
etag := strings.Trim(option.ETag, `"`)
|
||||||
|
etag = strings.TrimPrefix(etag, "W/")
|
||||||
|
|
||||||
|
if inm == etag {
|
||||||
|
// Resource not modified
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the content based on the option type
|
||||||
|
switch {
|
||||||
|
case option.FilePath != "":
|
||||||
filePath := wavebase.ExpandHomeDirSafe(option.FilePath)
|
filePath := wavebase.ExpandHomeDirSafe(option.FilePath)
|
||||||
http.ServeFile(w, r, filePath)
|
http.ServeFile(w, r, filePath)
|
||||||
} else if option.Data != nil {
|
|
||||||
// Set content length and serve content from in-memory data
|
case option.Data != nil:
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(option.Data)))
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(option.Data)))
|
||||||
w.WriteHeader(http.StatusOK) // Ensure headers are sent before writing body
|
w.WriteHeader(http.StatusOK)
|
||||||
if _, err := w.Write(option.Data); err != nil {
|
if _, err := w.Write(option.Data); err != nil {
|
||||||
http.Error(w, "Failed to serve content", http.StatusInternalServerError)
|
return fmt.Errorf("failed to write data: %v", err)
|
||||||
}
|
}
|
||||||
} else if option.File != nil {
|
|
||||||
// Write buffered data if available, then continue with remaining File content
|
case option.File != nil:
|
||||||
if bufferedData != nil {
|
if bufferedData != nil {
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(bufferedData)))
|
|
||||||
if _, err := w.Write(bufferedData); err != nil {
|
if _, err := w.Write(bufferedData); err != nil {
|
||||||
http.Error(w, "Failed to serve content", http.StatusInternalServerError)
|
return fmt.Errorf("failed to write buffered data: %v", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Serve remaining content from File
|
|
||||||
if _, err := io.Copy(w, option.File); err != nil {
|
if _, err := io.Copy(w, option.File); err != nil {
|
||||||
http.Error(w, "Failed to serve content", http.StatusInternalServerError)
|
return fmt.Errorf("failed to copy from file: %v", err)
|
||||||
}
|
}
|
||||||
} else if option.Reader != nil {
|
|
||||||
// Write buffered data if available, then continue with remaining Reader content
|
case option.Reader != nil:
|
||||||
if bufferedData != nil {
|
if bufferedData != nil {
|
||||||
if _, err := w.Write(bufferedData); err != nil {
|
if _, err := w.Write(bufferedData); err != nil {
|
||||||
http.Error(w, "Failed to serve content", http.StatusInternalServerError)
|
return fmt.Errorf("failed to write buffered data: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(w, option.Reader); err != nil {
|
||||||
|
return fmt.Errorf("failed to copy from reader: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("no content available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) RegisterFilePrefixHandler(prefix string, optionProvider func(path string) (*FileHandlerOption, error)) {
|
||||||
|
c.UrlHandlerMux.PathPrefix(prefix).HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
option, err := optionProvider(r.URL.Path)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if option == nil {
|
||||||
|
http.Error(w, "no content available", http.StatusNotFound)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// Serve remaining content from Reader
|
if err := ServeFileOption(w, r, *option); err != nil {
|
||||||
if _, err := io.Copy(w, option.Reader); err != nil {
|
http.Error(w, fmt.Sprintf("Failed to serve content: %v", err), http.StatusInternalServerError)
|
||||||
http.Error(w, "Failed to serve content", http.StatusInternalServerError)
|
}
|
||||||
}
|
})
|
||||||
} else {
|
}
|
||||||
http.Error(w, "No content available", http.StatusNotFound)
|
|
||||||
|
func (c *Client) RegisterFileHandler(path string, option FileHandlerOption) {
|
||||||
|
c.UrlHandlerMux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := ServeFileOption(w, r, option); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -100,7 +100,6 @@ func (impl *VDomServerImpl) VDomRenderCommand(ctx context.Context, feUpdate vdom
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (impl *VDomServerImpl) VDomUrlRequestCommand(ctx context.Context, data wshrpc.VDomUrlRequestData) chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse] {
|
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])
|
respChan := make(chan wshrpc.RespOrErrorUnion[wshrpc.VDomUrlRequestResponse])
|
||||||
writer := NewStreamingResponseWriter(respChan)
|
writer := NewStreamingResponseWriter(respChan)
|
||||||
|
|
||||||
@ -133,7 +132,10 @@ func (impl *VDomServerImpl) VDomUrlRequestCommand(ctx context.Context, data wshr
|
|||||||
for key, value := range data.Headers {
|
for key, value := range data.Headers {
|
||||||
httpReq.Header.Set(key, value)
|
httpReq.Header.Set(key, value)
|
||||||
}
|
}
|
||||||
|
if httpReq.URL.Path == "/wave/global.css" && impl.Client.GlobalStylesOption != nil {
|
||||||
|
ServeFileOption(writer, httpReq, *impl.Client.GlobalStylesOption)
|
||||||
|
return
|
||||||
|
}
|
||||||
if impl.Client.OverrideUrlHandler != nil {
|
if impl.Client.OverrideUrlHandler != nil {
|
||||||
impl.Client.OverrideUrlHandler.ServeHTTP(writer, httpReq)
|
impl.Client.OverrideUrlHandler.ServeHTTP(writer, httpReq)
|
||||||
return
|
return
|
||||||
|
@ -451,6 +451,7 @@ func RunWebServer(listener net.Listener) {
|
|||||||
gr.HandleFunc("/wave/file", WebFnWrap(WebFnOpts{AllowCaching: false}, handleWaveFile))
|
gr.HandleFunc("/wave/file", WebFnWrap(WebFnOpts{AllowCaching: false}, handleWaveFile))
|
||||||
gr.HandleFunc("/wave/service", WebFnWrap(WebFnOpts{JsonErrors: true}, handleService))
|
gr.HandleFunc("/wave/service", WebFnWrap(WebFnOpts{JsonErrors: true}, handleService))
|
||||||
gr.HandleFunc("/wave/log-active-state", WebFnWrap(WebFnOpts{JsonErrors: true}, handleLogActiveState))
|
gr.HandleFunc("/wave/log-active-state", WebFnWrap(WebFnOpts{JsonErrors: true}, handleLogActiveState))
|
||||||
|
gr.HandleFunc("/vdom/{uuid}/{path:.*}", WebFnWrap(WebFnOpts{AllowCaching: true}, handleVDom))
|
||||||
gr.PathPrefix(docsitePrefix).Handler(http.StripPrefix(docsitePrefix, docsite.GetDocsiteHandler()))
|
gr.PathPrefix(docsitePrefix).Handler(http.StripPrefix(docsitePrefix, docsite.GetDocsiteHandler()))
|
||||||
handler := http.TimeoutHandler(gr, HttpTimeoutDuration, "Timeout")
|
handler := http.TimeoutHandler(gr, HttpTimeoutDuration, "Timeout")
|
||||||
if wavebase.IsDevMode() {
|
if wavebase.IsDevMode() {
|
||||||
|
110
pkg/web/webvdomproto.go
Normal file
110
pkg/web/webvdomproto.go
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
// Copyright 2024, Command Line Inc.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/wavetermdev/waveterm/pkg/wshrpc"
|
||||||
|
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
|
||||||
|
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver"
|
||||||
|
"github.com/wavetermdev/waveterm/pkg/wshutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add the new handler function
|
||||||
|
func handleVDom(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Extract UUID and path from URL
|
||||||
|
pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/vdom/"), "/")
|
||||||
|
if len(pathParts) < 1 {
|
||||||
|
http.Error(w, "Invalid VDOM URL format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uuid := pathParts[0]
|
||||||
|
// Simple UUID validation
|
||||||
|
if len(uuid) != 36 {
|
||||||
|
http.Error(w, "Invalid UUID format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct the remaining path
|
||||||
|
path := "/" + strings.Join(pathParts[1:], "/")
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
path += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read request body if present
|
||||||
|
var body []byte
|
||||||
|
var err error
|
||||||
|
if r.Body != nil {
|
||||||
|
body, err = io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Error reading request body: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert headers to map
|
||||||
|
headers := make(map[string]string)
|
||||||
|
for key, values := range r.Header {
|
||||||
|
if len(values) > 0 {
|
||||||
|
headers[key] = values[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare RPC request data
|
||||||
|
data := wshrpc.VDomUrlRequestData{
|
||||||
|
Method: r.Method,
|
||||||
|
URL: path,
|
||||||
|
Headers: headers,
|
||||||
|
Body: body,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get RPC client
|
||||||
|
client := wshserver.GetMainRpcClient()
|
||||||
|
|
||||||
|
// Make RPC call with route to specific process
|
||||||
|
route := wshutil.MakeProcRouteId(uuid)
|
||||||
|
respCh := wshclient.VDomUrlRequestCommand(client, data, &wshrpc.RpcOpts{
|
||||||
|
Route: route,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle first response to set headers
|
||||||
|
firstResp := true
|
||||||
|
for respUnion := range respCh {
|
||||||
|
if respUnion.Error != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("RPC error: %v", respUnion.Error), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := respUnion.Response
|
||||||
|
if firstResp {
|
||||||
|
firstResp = false
|
||||||
|
// Set status code and headers from first response
|
||||||
|
if resp.StatusCode > 0 {
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
// Copy headers
|
||||||
|
for key, value := range resp.Headers {
|
||||||
|
w.Header().Set(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write body chunk if present
|
||||||
|
if len(resp.Body) > 0 {
|
||||||
|
_, err = w.Write(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error writing response: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user