@smithers-orchestrator/gateway-client 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/SmithersGatewayClient.ts +154 -30
- package/src/SmithersGatewayConnection.ts +90 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smithers-orchestrator/gateway-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Browser-first client SDK for the Smithers Gateway RPC and event stream APIs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"src/"
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@smithers-orchestrator/gateway": "0.
|
|
23
|
+
"@smithers-orchestrator/gateway": "0.19.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
|
@@ -15,6 +15,13 @@ type StreamRunEventPayload = {
|
|
|
15
15
|
payload?: unknown;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
type StreamDevToolsEventPayload = {
|
|
19
|
+
streamId?: string;
|
|
20
|
+
runId?: string;
|
|
21
|
+
event?: unknown;
|
|
22
|
+
error?: unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
18
25
|
declare global {
|
|
19
26
|
var __SMITHERS_GATEWAY_UI__: GatewayUiBootConfig | undefined;
|
|
20
27
|
}
|
|
@@ -26,25 +33,34 @@ function defaultBaseUrl() {
|
|
|
26
33
|
return "http://127.0.0.1:7331";
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
function isUnixWebSocketUrl(baseUrl: string) {
|
|
37
|
+
return /^ws\+unix:/i.test(baseUrl);
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
function normalizeBaseUrl(baseUrl: string) {
|
|
41
|
+
if (isUnixWebSocketUrl(baseUrl)) {
|
|
42
|
+
return baseUrl;
|
|
43
|
+
}
|
|
30
44
|
return baseUrl.replace(/\/+$/, "");
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
function toWebSocketUrl(baseUrl: string, wsPath = "/") {
|
|
48
|
+
if (isUnixWebSocketUrl(baseUrl)) {
|
|
49
|
+
const url = new URL(baseUrl);
|
|
50
|
+
const socketPath = url.pathname.split(":", 1)[0];
|
|
51
|
+
const path = wsPath.startsWith("/") ? wsPath : `/${wsPath}`;
|
|
52
|
+
url.pathname = `${socketPath}:${path}`;
|
|
53
|
+
url.search = "";
|
|
54
|
+
return url.toString();
|
|
55
|
+
}
|
|
34
56
|
const url = new URL(wsPath, baseUrl);
|
|
35
57
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
36
58
|
return url.toString();
|
|
37
59
|
}
|
|
38
60
|
|
|
39
|
-
|
|
40
|
-
const cryptoApi = globalThis.crypto;
|
|
41
|
-
const suffix = typeof cryptoApi?.randomUUID === "function"
|
|
42
|
-
? cryptoApi.randomUUID()
|
|
43
|
-
: Math.random().toString(36).slice(2);
|
|
44
|
-
return `${method}-${suffix}`;
|
|
45
|
-
}
|
|
61
|
+
const unavailableFetch = (() => Promise.reject(new Error("fetch is not available in this environment."))) as unknown as typeof fetch;
|
|
46
62
|
|
|
47
|
-
function headersFromOptions(options: SmithersGatewayClientOptions) {
|
|
63
|
+
function headersFromOptions(options: Pick<SmithersGatewayClientOptions, "headers" | "token">) {
|
|
48
64
|
const headers = new Headers(options.headers);
|
|
49
65
|
headers.set("content-type", "application/json");
|
|
50
66
|
if (options.token) {
|
|
@@ -53,6 +69,44 @@ function headersFromOptions(options: SmithersGatewayClientOptions) {
|
|
|
53
69
|
return headers;
|
|
54
70
|
}
|
|
55
71
|
|
|
72
|
+
function gatewayHttpError(method: string, status: number, message = `Gateway HTTP ${status}`) {
|
|
73
|
+
return new GatewayRpcError({
|
|
74
|
+
method,
|
|
75
|
+
status,
|
|
76
|
+
code: "HTTP_ERROR",
|
|
77
|
+
message,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function invalidGatewayResponse(method: string, status: number | undefined, details?: unknown) {
|
|
82
|
+
return new GatewayRpcError({
|
|
83
|
+
method,
|
|
84
|
+
status,
|
|
85
|
+
code: "INVALID_GATEWAY_RESPONSE",
|
|
86
|
+
message: "Gateway returned an invalid RPC response frame.",
|
|
87
|
+
details,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
92
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isGatewayResponseFrame(value: unknown): value is GatewayResponseFrame {
|
|
96
|
+
if (!isObject(value)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (value.type !== "res" || typeof value.id !== "string" || typeof value.ok !== "boolean") {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if (value.ok === true) {
|
|
103
|
+
return "payload" in value;
|
|
104
|
+
}
|
|
105
|
+
return isObject(value.error) &&
|
|
106
|
+
typeof value.error.code === "string" &&
|
|
107
|
+
typeof value.error.message === "string";
|
|
108
|
+
}
|
|
109
|
+
|
|
56
110
|
function rpcError(frame: Extract<GatewayResponseFrame, { ok: false }>, method: string, status?: number) {
|
|
57
111
|
return new GatewayRpcError({
|
|
58
112
|
method,
|
|
@@ -69,7 +123,7 @@ export class SmithersGatewayClient {
|
|
|
69
123
|
readonly baseUrl: string;
|
|
70
124
|
readonly token?: string;
|
|
71
125
|
readonly fetchImpl: typeof fetch;
|
|
72
|
-
readonly WebSocketImpl: typeof WebSocket;
|
|
126
|
+
readonly WebSocketImpl: typeof WebSocket | undefined;
|
|
73
127
|
readonly headers: HeadersInit | undefined;
|
|
74
128
|
readonly client: Required<NonNullable<SmithersGatewayClientOptions["client"]>>;
|
|
75
129
|
readonly boot: GatewayUiBootConfig | undefined;
|
|
@@ -79,7 +133,11 @@ export class SmithersGatewayClient {
|
|
|
79
133
|
this.baseUrl = normalizeBaseUrl(options.baseUrl ?? defaultBaseUrl());
|
|
80
134
|
this.token = options.token;
|
|
81
135
|
this.headers = options.headers;
|
|
82
|
-
this.fetchImpl = options.fetch ??
|
|
136
|
+
this.fetchImpl = options.fetch ?? (
|
|
137
|
+
typeof globalThis.fetch === "function"
|
|
138
|
+
? globalThis.fetch.bind(globalThis)
|
|
139
|
+
: unavailableFetch
|
|
140
|
+
);
|
|
83
141
|
this.WebSocketImpl = options.WebSocket ?? globalThis.WebSocket;
|
|
84
142
|
this.client = {
|
|
85
143
|
id: options.client?.id ?? "smithers-gateway-client",
|
|
@@ -103,14 +161,20 @@ export class SmithersGatewayClient {
|
|
|
103
161
|
body: JSON.stringify(params ?? {}),
|
|
104
162
|
signal: options.signal,
|
|
105
163
|
});
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
164
|
+
let frame: unknown;
|
|
165
|
+
try {
|
|
166
|
+
frame = await response.json();
|
|
167
|
+
} catch {
|
|
168
|
+
if (response.ok) {
|
|
169
|
+
throw invalidGatewayResponse(method, response.status);
|
|
170
|
+
}
|
|
171
|
+
throw gatewayHttpError(method, response.status);
|
|
172
|
+
}
|
|
173
|
+
if (!isGatewayResponseFrame(frame)) {
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
throw gatewayHttpError(method, response.status);
|
|
176
|
+
}
|
|
177
|
+
throw invalidGatewayResponse(method, response.status, frame);
|
|
114
178
|
}
|
|
115
179
|
if (!frame.ok) {
|
|
116
180
|
throw rpcError(frame, method, response.status);
|
|
@@ -122,6 +186,9 @@ export class SmithersGatewayClient {
|
|
|
122
186
|
if (!this.WebSocketImpl) {
|
|
123
187
|
throw new Error("WebSocket is not available in this environment.");
|
|
124
188
|
}
|
|
189
|
+
if (options.signal?.aborted) {
|
|
190
|
+
throw new Error("Gateway WebSocket open aborted.");
|
|
191
|
+
}
|
|
125
192
|
const ws = new this.WebSocketImpl(toWebSocketUrl(this.baseUrl, this.boot?.wsPath));
|
|
126
193
|
await new Promise<void>((resolve, reject) => {
|
|
127
194
|
const onOpen = () => {
|
|
@@ -132,26 +199,33 @@ export class SmithersGatewayClient {
|
|
|
132
199
|
cleanup();
|
|
133
200
|
reject(new Error("Gateway WebSocket failed to open."));
|
|
134
201
|
};
|
|
202
|
+
const onAbort = () => {
|
|
203
|
+
cleanup();
|
|
204
|
+
ws.close();
|
|
205
|
+
reject(new Error("Gateway WebSocket open aborted."));
|
|
206
|
+
};
|
|
135
207
|
const cleanup = () => {
|
|
136
208
|
ws.removeEventListener("open", onOpen);
|
|
137
209
|
ws.removeEventListener("error", onError);
|
|
210
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
138
211
|
};
|
|
139
212
|
ws.addEventListener("open", onOpen);
|
|
140
213
|
ws.addEventListener("error", onError);
|
|
141
|
-
options.signal?.addEventListener("abort",
|
|
142
|
-
cleanup();
|
|
143
|
-
ws.close();
|
|
144
|
-
reject(new Error("Gateway WebSocket open aborted."));
|
|
145
|
-
}, { once: true });
|
|
214
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
146
215
|
});
|
|
147
216
|
const connection = new SmithersGatewayConnection(ws);
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
217
|
+
try {
|
|
218
|
+
await connection.requestRaw("connect", {
|
|
219
|
+
minProtocol: 1,
|
|
220
|
+
maxProtocol: 1,
|
|
221
|
+
client: this.client,
|
|
222
|
+
...(this.token ? { auth: { token: this.token } } : {}),
|
|
223
|
+
...(options.subscribe ? { subscribe: options.subscribe } : {}),
|
|
224
|
+
});
|
|
225
|
+
} catch (error) {
|
|
226
|
+
connection.close();
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
155
229
|
return connection;
|
|
156
230
|
}
|
|
157
231
|
|
|
@@ -181,6 +255,32 @@ export class SmithersGatewayClient {
|
|
|
181
255
|
}
|
|
182
256
|
}
|
|
183
257
|
|
|
258
|
+
async *streamDevTools(
|
|
259
|
+
params: GatewayRpcParams<"streamDevTools">,
|
|
260
|
+
options: { signal?: AbortSignal } = {},
|
|
261
|
+
): AsyncGenerator<GatewayEventFrame<StreamDevToolsEventPayload>> {
|
|
262
|
+
const connection = await this.connect({ subscribe: [params.runId], signal: options.signal });
|
|
263
|
+
try {
|
|
264
|
+
const subscribed = await connection.request("streamDevTools", params);
|
|
265
|
+
if (!isObject(subscribed) || typeof subscribed.streamId !== "string") {
|
|
266
|
+
throw invalidGatewayResponse("streamDevTools", undefined, subscribed);
|
|
267
|
+
}
|
|
268
|
+
for await (const frame of connection.events(options.signal)) {
|
|
269
|
+
if (
|
|
270
|
+
(frame.event === "devtools.event" || frame.event === "devtools.error") &&
|
|
271
|
+
typeof frame.payload === "object" &&
|
|
272
|
+
frame.payload !== null &&
|
|
273
|
+
"streamId" in frame.payload &&
|
|
274
|
+
frame.payload.streamId === subscribed.streamId
|
|
275
|
+
) {
|
|
276
|
+
yield frame as GatewayEventFrame<StreamDevToolsEventPayload>;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} finally {
|
|
280
|
+
connection.close();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
184
284
|
launchRun(params: GatewayRpcParams<"launchRun">) {
|
|
185
285
|
return this.rpc("launchRun", params);
|
|
186
286
|
}
|
|
@@ -193,6 +293,14 @@ export class SmithersGatewayClient {
|
|
|
193
293
|
return this.rpc("cancelRun", params);
|
|
194
294
|
}
|
|
195
295
|
|
|
296
|
+
hijackRun(params: GatewayRpcParams<"hijackRun">) {
|
|
297
|
+
return this.rpc("hijackRun", params);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
rewindRun(params: GatewayRpcParams<"rewindRun">) {
|
|
301
|
+
return this.rpc("rewindRun", params);
|
|
302
|
+
}
|
|
303
|
+
|
|
196
304
|
submitApproval(params: GatewayRpcParams<"submitApproval">) {
|
|
197
305
|
return this.rpc("submitApproval", params);
|
|
198
306
|
}
|
|
@@ -224,4 +332,20 @@ export class SmithersGatewayClient {
|
|
|
224
332
|
getNodeDiff(params: GatewayRpcParams<"getNodeDiff">) {
|
|
225
333
|
return this.rpc("getNodeDiff", params);
|
|
226
334
|
}
|
|
335
|
+
|
|
336
|
+
cronList(params: GatewayRpcParams<"cronList"> = {}) {
|
|
337
|
+
return this.rpc("cronList", params);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
cronCreate(params: GatewayRpcParams<"cronCreate">) {
|
|
341
|
+
return this.rpc("cronCreate", params);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
cronDelete(params: GatewayRpcParams<"cronDelete">) {
|
|
345
|
+
return this.rpc("cronDelete", params);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
cronRun(params: GatewayRpcParams<"cronRun">) {
|
|
349
|
+
return this.rpc("cronRun", params);
|
|
350
|
+
}
|
|
227
351
|
}
|
|
@@ -34,6 +34,42 @@ function frameError(frame: Extract<GatewayResponseFrame, { ok: false }>, method:
|
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function invalidFrameError(details?: unknown) {
|
|
42
|
+
return new GatewayRpcError({
|
|
43
|
+
method: "websocket",
|
|
44
|
+
code: "INVALID_GATEWAY_RESPONSE",
|
|
45
|
+
message: "Gateway returned an invalid WebSocket frame.",
|
|
46
|
+
details,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isGatewayResponseFrame(value: unknown): value is GatewayResponseFrame {
|
|
51
|
+
if (!isObject(value)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
if (value.type !== "res" || typeof value.id !== "string" || typeof value.ok !== "boolean") {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (value.ok === true) {
|
|
58
|
+
return "payload" in value;
|
|
59
|
+
}
|
|
60
|
+
return isObject(value.error) &&
|
|
61
|
+
typeof value.error.code === "string" &&
|
|
62
|
+
typeof value.error.message === "string";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isGatewayEventFrame(value: unknown): value is GatewayEventFrame {
|
|
66
|
+
return isObject(value) &&
|
|
67
|
+
value.type === "event" &&
|
|
68
|
+
typeof value.event === "string" &&
|
|
69
|
+
typeof value.seq === "number" &&
|
|
70
|
+
typeof value.stateVersion === "number";
|
|
71
|
+
}
|
|
72
|
+
|
|
37
73
|
export class SmithersGatewayConnection {
|
|
38
74
|
readonly ws: WebSocket;
|
|
39
75
|
readonly pending = new Map<string, PendingRequest>();
|
|
@@ -50,12 +86,12 @@ export class SmithersGatewayConnection {
|
|
|
50
86
|
this.push({ kind: "error", error: new Error("Gateway WebSocket error") });
|
|
51
87
|
});
|
|
52
88
|
ws.addEventListener("close", () => {
|
|
89
|
+
const alreadyClosed = this.closed;
|
|
53
90
|
this.closed = true;
|
|
54
|
-
|
|
55
|
-
|
|
91
|
+
this.rejectPending(new Error("Gateway WebSocket closed"));
|
|
92
|
+
if (!alreadyClosed) {
|
|
93
|
+
this.push({ kind: "close" });
|
|
56
94
|
}
|
|
57
|
-
this.pending.clear();
|
|
58
|
-
this.push({ kind: "close" });
|
|
59
95
|
});
|
|
60
96
|
}
|
|
61
97
|
|
|
@@ -63,6 +99,9 @@ export class SmithersGatewayConnection {
|
|
|
63
99
|
method: Method,
|
|
64
100
|
params: GatewayRpcParams<Method>,
|
|
65
101
|
): Promise<GatewayRpcPayload<Method>> {
|
|
102
|
+
if (this.closed) {
|
|
103
|
+
return Promise.reject(new Error("Gateway WebSocket is closed"));
|
|
104
|
+
}
|
|
66
105
|
const id = randomId(method);
|
|
67
106
|
const frame = { type: "req", id, method, params };
|
|
68
107
|
return new Promise((resolve, reject) => {
|
|
@@ -71,21 +110,38 @@ export class SmithersGatewayConnection {
|
|
|
71
110
|
resolve: (payload) => resolve(payload as GatewayRpcPayload<Method>),
|
|
72
111
|
reject,
|
|
73
112
|
});
|
|
74
|
-
|
|
113
|
+
try {
|
|
114
|
+
this.ws.send(JSON.stringify(frame));
|
|
115
|
+
} catch (cause) {
|
|
116
|
+
this.pending.delete(id);
|
|
117
|
+
reject(cause instanceof Error ? cause : new Error(String(cause)));
|
|
118
|
+
}
|
|
75
119
|
});
|
|
76
120
|
}
|
|
77
121
|
|
|
78
122
|
requestRaw(method: string, params?: unknown): Promise<unknown> {
|
|
123
|
+
if (this.closed) {
|
|
124
|
+
return Promise.reject(new Error("Gateway WebSocket is closed"));
|
|
125
|
+
}
|
|
79
126
|
const id = randomId(method);
|
|
80
127
|
const frame = { type: "req", id, method, params };
|
|
81
128
|
return new Promise((resolve, reject) => {
|
|
82
129
|
this.pending.set(id, { method, resolve, reject });
|
|
83
|
-
|
|
130
|
+
try {
|
|
131
|
+
this.ws.send(JSON.stringify(frame));
|
|
132
|
+
} catch (cause) {
|
|
133
|
+
this.pending.delete(id);
|
|
134
|
+
reject(cause instanceof Error ? cause : new Error(String(cause)));
|
|
135
|
+
}
|
|
84
136
|
});
|
|
85
137
|
}
|
|
86
138
|
|
|
87
139
|
async *events(signal?: AbortSignal): AsyncGenerator<GatewayEventFrame> {
|
|
88
140
|
const abort = () => this.close();
|
|
141
|
+
if (signal?.aborted) {
|
|
142
|
+
this.close();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
89
145
|
signal?.addEventListener("abort", abort, { once: true });
|
|
90
146
|
try {
|
|
91
147
|
while (!this.closed || this.queue.length > 0) {
|
|
@@ -108,13 +164,21 @@ export class SmithersGatewayConnection {
|
|
|
108
164
|
return;
|
|
109
165
|
}
|
|
110
166
|
this.closed = true;
|
|
167
|
+
this.rejectPending(new Error("Gateway WebSocket closed"));
|
|
168
|
+
this.push({ kind: "close" });
|
|
111
169
|
this.ws.close();
|
|
112
170
|
}
|
|
113
171
|
|
|
114
172
|
private handleMessage(raw: unknown) {
|
|
115
173
|
const text = typeof raw === "string" ? raw : String(raw);
|
|
116
|
-
|
|
117
|
-
|
|
174
|
+
let frame: unknown;
|
|
175
|
+
try {
|
|
176
|
+
frame = JSON.parse(text);
|
|
177
|
+
} catch {
|
|
178
|
+
this.push({ kind: "error", error: invalidFrameError(text) });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (isGatewayResponseFrame(frame)) {
|
|
118
182
|
const pending = this.pending.get(frame.id);
|
|
119
183
|
if (!pending) {
|
|
120
184
|
return;
|
|
@@ -127,9 +191,19 @@ export class SmithersGatewayConnection {
|
|
|
127
191
|
pending.reject(frameError(frame, pending.method));
|
|
128
192
|
return;
|
|
129
193
|
}
|
|
130
|
-
if (frame.type === "
|
|
194
|
+
if (isObject(frame) && frame.type === "res" && typeof frame.id === "string") {
|
|
195
|
+
const pending = this.pending.get(frame.id);
|
|
196
|
+
if (pending) {
|
|
197
|
+
this.pending.delete(frame.id);
|
|
198
|
+
pending.reject(invalidFrameError(frame));
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (isGatewayEventFrame(frame)) {
|
|
131
203
|
this.push({ kind: "event", frame });
|
|
204
|
+
return;
|
|
132
205
|
}
|
|
206
|
+
this.push({ kind: "error", error: invalidFrameError(frame) });
|
|
133
207
|
}
|
|
134
208
|
|
|
135
209
|
private push(event: QueuedEvent) {
|
|
@@ -151,4 +225,11 @@ export class SmithersGatewayConnection {
|
|
|
151
225
|
}
|
|
152
226
|
return this.queue.shift();
|
|
153
227
|
}
|
|
228
|
+
|
|
229
|
+
private rejectPending(error: Error) {
|
|
230
|
+
for (const pending of this.pending.values()) {
|
|
231
|
+
pending.reject(error);
|
|
232
|
+
}
|
|
233
|
+
this.pending.clear();
|
|
234
|
+
}
|
|
154
235
|
}
|