jbs-client 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +54 -0
- package/README.md +61 -0
- package/build.ts +162 -0
- package/bun.lock +234 -0
- package/package.json +22 -0
- package/scripts/npm/README.md +14 -0
- package/scripts/npm/bin/jbs-client +70 -0
- package/scripts/npm/postinstall.mjs +62 -0
- package/src/app.tsx +239 -0
- package/src/components/action-menu.tsx +28 -0
- package/src/components/log-panel.tsx +31 -0
- package/src/components/param-form.tsx +73 -0
- package/src/components.test.tsx +36 -0
- package/src/index.tsx +19 -0
- package/src/protocol.test.ts +40 -0
- package/src/protocol.ts +201 -0
- package/src/state.test.ts +31 -0
- package/src/state.ts +77 -0
- package/src/types.ts +34 -0
- package/src/ws.ts +63 -0
- package/tsconfig.json +15 -0
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { FunctionDefinition } from "./types.js"
|
|
2
|
+
|
|
3
|
+
const engineOptions = [
|
|
4
|
+
{ name: "ASM", description: "Default bytecode engine", value: "asm" },
|
|
5
|
+
{ name: "Javassist", description: "Source-level transformer", value: "javassist" }
|
|
6
|
+
]
|
|
7
|
+
|
|
8
|
+
function classAndMethodChecker(value: string): boolean {
|
|
9
|
+
return value.split("#").length === 2
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function nonEmptyChecker(value: string): boolean {
|
|
13
|
+
return value.trim().length > 0
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function engineToMode(value: string): number {
|
|
17
|
+
return value === "javassist" ? 0 : 1
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function randomString(length: number): string {
|
|
21
|
+
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
22
|
+
let result = ""
|
|
23
|
+
for (let index = 0; index < length; index += 1) {
|
|
24
|
+
result += alphabet[Math.floor(Math.random() * alphabet.length)]
|
|
25
|
+
}
|
|
26
|
+
return result
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function commonMap(): Record<string, unknown> {
|
|
30
|
+
return {
|
|
31
|
+
id: randomString(4),
|
|
32
|
+
timestamp: Date.now()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseParamTypes(value: string): string[] {
|
|
37
|
+
return value
|
|
38
|
+
.split(",")
|
|
39
|
+
.map((part) => part.trim())
|
|
40
|
+
.filter((part) => part.length > 0)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function splitSignature(value: string): [string, string] {
|
|
44
|
+
const [className = "", method = ""] = value.split("#")
|
|
45
|
+
return [className, method]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const menu: FunctionDefinition[] = [
|
|
49
|
+
{
|
|
50
|
+
name: "Watch",
|
|
51
|
+
params: [
|
|
52
|
+
{ name: "ClassName#MethodName", inputType: "text", checker: classAndMethodChecker, value: "" },
|
|
53
|
+
{ name: "MinCost", inputType: "text", checker: () => true, value: "0" }
|
|
54
|
+
],
|
|
55
|
+
toPayload: (params: string[]) => JSON.stringify({ ...commonMap(), type: "WATCH", signature: params[0], minCost: Number.parseInt(params[1] || "0", 10) || 0 })
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "OuterWatch",
|
|
59
|
+
params: [
|
|
60
|
+
{ name: "ClassName#MethodName", inputType: "text", checker: classAndMethodChecker, value: "" },
|
|
61
|
+
{ name: "InnerClassName#InnerMethodName", inputType: "text", checker: classAndMethodChecker, value: "" }
|
|
62
|
+
],
|
|
63
|
+
toPayload: (params: string[]) => JSON.stringify({ ...commonMap(), type: "OUTER_WATCH", signature: params[0], innerSignature: params[1] })
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "Trace",
|
|
67
|
+
params: [
|
|
68
|
+
{ name: "ClassName#MethodName", inputType: "text", checker: classAndMethodChecker, value: "" },
|
|
69
|
+
{ name: "MinCost", inputType: "text", checker: () => true, value: "0" },
|
|
70
|
+
{ name: "IgnoreSubMethodZeroCost", inputType: "text", checker: () => true, value: "true" }
|
|
71
|
+
],
|
|
72
|
+
toPayload: (params: string[]) => JSON.stringify({
|
|
73
|
+
...commonMap(),
|
|
74
|
+
type: "TRACE",
|
|
75
|
+
signature: params[0],
|
|
76
|
+
minCost: Number.parseInt(params[1] || "0", 10) || 0,
|
|
77
|
+
ignoreZero: params[2] === "true"
|
|
78
|
+
})
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "ChangeBody",
|
|
82
|
+
params: [
|
|
83
|
+
{ name: "ClassName#MethodName", inputType: "text", checker: classAndMethodChecker, value: "" },
|
|
84
|
+
{ name: "ParamTypes", inputType: "text", checker: () => true, value: "" },
|
|
85
|
+
{ name: "Engine", inputType: "select", checker: () => true, value: "asm", options: engineOptions },
|
|
86
|
+
{ name: "Body", inputType: "textarea", checker: () => true, value: "" }
|
|
87
|
+
],
|
|
88
|
+
toPayload: (params: string[]) => {
|
|
89
|
+
const [className, method] = splitSignature(params[0])
|
|
90
|
+
return JSON.stringify({
|
|
91
|
+
...commonMap(),
|
|
92
|
+
type: "CHANGE_BODY",
|
|
93
|
+
className,
|
|
94
|
+
method,
|
|
95
|
+
paramTypes: parseParamTypes(params[1]),
|
|
96
|
+
body: params[3],
|
|
97
|
+
mode: engineToMode(params[2])
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "ChangeResult",
|
|
103
|
+
params: [
|
|
104
|
+
{ name: "ClassName#MethodName", inputType: "text", checker: classAndMethodChecker, value: "" },
|
|
105
|
+
{ name: "ParamTypes", inputType: "text", checker: () => true, value: "" },
|
|
106
|
+
{ name: "InnerClassName#InnerMethodName", inputType: "text", checker: classAndMethodChecker, value: "" },
|
|
107
|
+
{ name: "Engine", inputType: "select", checker: () => true, value: "asm", options: engineOptions },
|
|
108
|
+
{ name: "Body", inputType: "textarea", checker: () => true, value: "" }
|
|
109
|
+
],
|
|
110
|
+
toPayload: (params: string[]) => {
|
|
111
|
+
const [className, method] = splitSignature(params[0])
|
|
112
|
+
const [innerClassName, innerMethod] = splitSignature(params[2])
|
|
113
|
+
return JSON.stringify({
|
|
114
|
+
...commonMap(),
|
|
115
|
+
type: "CHANGE_RESULT",
|
|
116
|
+
className,
|
|
117
|
+
method,
|
|
118
|
+
paramTypes: parseParamTypes(params[1]),
|
|
119
|
+
innerClassName,
|
|
120
|
+
innerMethod,
|
|
121
|
+
body: params[4],
|
|
122
|
+
mode: engineToMode(params[3])
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "Decompile",
|
|
128
|
+
params: [
|
|
129
|
+
{ name: "ClassName", inputType: "text", checker: nonEmptyChecker, value: "" }
|
|
130
|
+
],
|
|
131
|
+
toPayload: (params: string[]) => JSON.stringify({
|
|
132
|
+
...commonMap(),
|
|
133
|
+
type: "DECOMPILE",
|
|
134
|
+
className: params[0]
|
|
135
|
+
})
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "Eval",
|
|
139
|
+
params: [
|
|
140
|
+
{
|
|
141
|
+
name: "Body",
|
|
142
|
+
inputType: "textarea",
|
|
143
|
+
checker: nonEmptyChecker,
|
|
144
|
+
value: "ctx.getBeanDefinitionNames().length"
|
|
145
|
+
}
|
|
146
|
+
],
|
|
147
|
+
toPayload: (params: string[]) => JSON.stringify({
|
|
148
|
+
...commonMap(),
|
|
149
|
+
type: "EVAL",
|
|
150
|
+
body: params[0]
|
|
151
|
+
})
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "Exec",
|
|
155
|
+
params: [
|
|
156
|
+
{
|
|
157
|
+
name: "Body",
|
|
158
|
+
inputType: "textarea",
|
|
159
|
+
checker: () => true,
|
|
160
|
+
value: `
|
|
161
|
+
try {
|
|
162
|
+
ApplicationContext ctx =
|
|
163
|
+
(ApplicationContext) SpringUtils.getSpringBootApplicationContext();
|
|
164
|
+
Global.info(Arrays.toString(ctx.getBeanDefinitionNames()));
|
|
165
|
+
} catch (Exception e) {
|
|
166
|
+
Global.error(e.toString(), e);
|
|
167
|
+
}
|
|
168
|
+
`
|
|
169
|
+
}
|
|
170
|
+
],
|
|
171
|
+
toPayload: (params: string[]) => JSON.stringify({
|
|
172
|
+
...commonMap(),
|
|
173
|
+
type: "EXEC",
|
|
174
|
+
body: `package w;
|
|
175
|
+
import w.Global;
|
|
176
|
+
import w.util.SpringUtils;
|
|
177
|
+
import org.springframework.context.ApplicationContext;
|
|
178
|
+
import java.util.*;
|
|
179
|
+
public class Exec{
|
|
180
|
+
public void exec() {${params[0] ?? ""}}
|
|
181
|
+
}`
|
|
182
|
+
})
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "Reset",
|
|
186
|
+
params: [],
|
|
187
|
+
toPayload: () => JSON.stringify({ ...commonMap(), type: "RESET" })
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
export function buildInitialFormValues(actionIndex: number): string[] {
|
|
192
|
+
return menu[actionIndex].params.map((param: FunctionDefinition["params"][number]) => param.value)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function validateAction(actionIndex: number, values: string[]): boolean {
|
|
196
|
+
return menu[actionIndex].params.every((param: FunctionDefinition["params"][number], index: number) => param.checker(values[index] ?? ""))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function buildPayload(actionIndex: number, values: string[]): string {
|
|
200
|
+
return menu[actionIndex].toPayload(values)
|
|
201
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { appendLog, backToMenu, createInitialState, nextFocus, openAction, previousFocus, updateFormValue, updateSelectedAction } from "./state.js"
|
|
3
|
+
|
|
4
|
+
describe("state helpers", () => {
|
|
5
|
+
test("openAction builds form state", () => {
|
|
6
|
+
const initial = updateSelectedAction(createInitialState(), 3)
|
|
7
|
+
const opened = openAction(initial)
|
|
8
|
+
expect(opened.screen).toBe("form")
|
|
9
|
+
expect(opened.focusIndex).toBe(0)
|
|
10
|
+
expect(opened.formValues.length).toBe(4)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test("focus wraps across inputs and submit button", () => {
|
|
14
|
+
const opened = openAction(updateSelectedAction(createInitialState(), 0))
|
|
15
|
+
expect(nextFocus(opened, 2).focusIndex).toBe(1)
|
|
16
|
+
expect(nextFocus({ ...opened, focusIndex: 2 }, 2).focusIndex).toBe(0)
|
|
17
|
+
expect(previousFocus(opened, 2).focusIndex).toBe(2)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("updateFormValue and backToMenu keep expected data", () => {
|
|
21
|
+
const opened = openAction(updateSelectedAction(createInitialState(), 5))
|
|
22
|
+
const updated = updateFormValue(opened, 0, "hello")
|
|
23
|
+
expect(updated.formValues[0]).toBe("hello")
|
|
24
|
+
expect(backToMenu(updated).screen).toBe("menu")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("appendLog keeps newest messages first", () => {
|
|
28
|
+
const state = appendLog(appendLog(createInitialState(), "first"), "second")
|
|
29
|
+
expect(state.logs).toEqual(["first", "second"])
|
|
30
|
+
})
|
|
31
|
+
})
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { buildInitialFormValues } from "./protocol.js"
|
|
2
|
+
import type { AppState, ConnectionStatus } from "./types.js"
|
|
3
|
+
|
|
4
|
+
const logMaxLength = 200
|
|
5
|
+
|
|
6
|
+
export function createInitialState(): AppState {
|
|
7
|
+
return {
|
|
8
|
+
screen: "menu",
|
|
9
|
+
selectedActionIndex: 0,
|
|
10
|
+
focusIndex: 0,
|
|
11
|
+
formValues: [],
|
|
12
|
+
logs: [],
|
|
13
|
+
connectionStatus: "idle"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function openAction(state: AppState, actionIndex = state.selectedActionIndex): AppState {
|
|
18
|
+
return {
|
|
19
|
+
...state,
|
|
20
|
+
screen: "form",
|
|
21
|
+
selectedActionIndex: actionIndex,
|
|
22
|
+
focusIndex: 0,
|
|
23
|
+
formValues: buildInitialFormValues(actionIndex)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function backToMenu(state: AppState): AppState {
|
|
28
|
+
return {
|
|
29
|
+
...state,
|
|
30
|
+
screen: "menu",
|
|
31
|
+
focusIndex: 0
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function updateSelectedAction(state: AppState, actionIndex: number): AppState {
|
|
36
|
+
return {
|
|
37
|
+
...state,
|
|
38
|
+
selectedActionIndex: actionIndex
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function updateFormValue(state: AppState, index: number, value: string): AppState {
|
|
43
|
+
const formValues = [...state.formValues]
|
|
44
|
+
formValues[index] = value
|
|
45
|
+
return {
|
|
46
|
+
...state,
|
|
47
|
+
formValues
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function nextFocus(state: AppState, inputCount: number): AppState {
|
|
52
|
+
return {
|
|
53
|
+
...state,
|
|
54
|
+
focusIndex: (state.focusIndex + 1) % (inputCount + 1)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function previousFocus(state: AppState, inputCount: number): AppState {
|
|
59
|
+
return {
|
|
60
|
+
...state,
|
|
61
|
+
focusIndex: (state.focusIndex - 1 + inputCount + 1) % (inputCount + 1)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function appendLog(state: AppState, message: string): AppState {
|
|
66
|
+
return {
|
|
67
|
+
...state,
|
|
68
|
+
logs: [...state.logs, message].slice(-logMaxLength)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function setConnectionStatus(state: AppState, status: ConnectionStatus): AppState {
|
|
73
|
+
return {
|
|
74
|
+
...state,
|
|
75
|
+
connectionStatus: status
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type ParamOption = {
|
|
2
|
+
name: string
|
|
3
|
+
description: string
|
|
4
|
+
value: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type ParamInputType = "text" | "textarea" | "select"
|
|
8
|
+
|
|
9
|
+
export type ParamDefinition = {
|
|
10
|
+
name: string
|
|
11
|
+
inputType: ParamInputType
|
|
12
|
+
checker: (value: string) => boolean
|
|
13
|
+
value: string
|
|
14
|
+
options?: ParamOption[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type FunctionDefinition = {
|
|
18
|
+
name: string
|
|
19
|
+
params: ParamDefinition[]
|
|
20
|
+
toPayload: (params: string[]) => string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Screen = "menu" | "form"
|
|
24
|
+
|
|
25
|
+
export type ConnectionStatus = "idle" | "connecting" | "connected" | "disconnected" | "error"
|
|
26
|
+
|
|
27
|
+
export type AppState = {
|
|
28
|
+
screen: Screen
|
|
29
|
+
selectedActionIndex: number
|
|
30
|
+
focusIndex: number
|
|
31
|
+
formValues: string[]
|
|
32
|
+
logs: string[]
|
|
33
|
+
connectionStatus: ConnectionStatus
|
|
34
|
+
}
|
package/src/ws.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type ConnectionOptions = {
|
|
2
|
+
url: string
|
|
3
|
+
enabled: boolean
|
|
4
|
+
onLog: (message: string) => void
|
|
5
|
+
onStatusChange: (status: "connecting" | "connected" | "disconnected" | "error") => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function formatLog(content: string): string {
|
|
9
|
+
const timestamp = new Date()
|
|
10
|
+
const formatted = timestamp.toISOString().replace("T", " ").slice(0, 19)
|
|
11
|
+
return `[${formatted}] ${content}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function connectWebSocket(options: ConnectionOptions): { send: (message: string) => void; dispose: () => void } {
|
|
15
|
+
let ws: WebSocket | null = null
|
|
16
|
+
let closed = false
|
|
17
|
+
|
|
18
|
+
if (options.enabled) {
|
|
19
|
+
options.onStatusChange("connecting")
|
|
20
|
+
try {
|
|
21
|
+
ws = new WebSocket(options.url)
|
|
22
|
+
ws.onopen = () => {
|
|
23
|
+
options.onStatusChange("connected")
|
|
24
|
+
options.onLog(formatLog(`WebSocket connected: ${options.url}`))
|
|
25
|
+
}
|
|
26
|
+
ws.onmessage = (event) => {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(String(event.data)) as { content?: string }
|
|
29
|
+
options.onLog(formatLog(parsed.content ?? String(event.data)))
|
|
30
|
+
} catch {
|
|
31
|
+
options.onLog(formatLog(String(event.data)))
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
ws.onerror = () => {
|
|
35
|
+
options.onStatusChange("error")
|
|
36
|
+
options.onLog(formatLog(`WebSocket error: ${options.url}`))
|
|
37
|
+
}
|
|
38
|
+
ws.onclose = () => {
|
|
39
|
+
if (!closed) {
|
|
40
|
+
options.onStatusChange("disconnected")
|
|
41
|
+
options.onLog(formatLog(`WebSocket disconnected: ${options.url}`))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
options.onStatusChange("error")
|
|
46
|
+
options.onLog(formatLog(`WebSocket connect failed (${options.url}): ${String(error)}`))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
send(message: string) {
|
|
52
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
53
|
+
options.onLog(formatLog("WebSocket is not connected"))
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
ws.send(message)
|
|
57
|
+
},
|
|
58
|
+
dispose() {
|
|
59
|
+
closed = true
|
|
60
|
+
ws?.close()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext", "DOM"],
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"jsxImportSource": "@opentui/react",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"types": ["bun-types"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*", "build.ts"]
|
|
15
|
+
}
|