@trevrpc/trevrpc-js 0.2.2 → 1.0.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/README.md +89 -47
- package/bin/protoc-gen-trevrpc-js.js +1 -1
- package/package.json +11 -41
- package/src/bytes.js +10 -0
- package/src/call.js +287 -0
- package/src/channel.js +131 -296
- package/src/client.js +203 -1292
- package/src/framing.js +125 -519
- package/src/generator.js +26 -103
- package/src/index.d.ts +79 -452
- package/src/index.js +4 -3
- package/src/status.js +24 -70
- package/src/wire.js +4 -59
- package/examples/greeter/README.md +0 -35
- package/examples/greeter/client.js +0 -172
- package/examples/greeter/greeter.node.trevrpc.d.ts +0 -53
- package/examples/greeter/greeter.node.trevrpc.js +0 -9
- package/examples/greeter/greeter.proto +0 -18
- package/examples/greeter/greeter.trevrpc.d.ts +0 -55
- package/examples/greeter/greeter.trevrpc.js +0 -103
- package/examples/greeter/index.html +0 -140
- package/examples/greeter/static-server.js +0 -168
- package/src/advanced.d.ts +0 -29
- package/src/advanced.js +0 -1
- package/src/browser-channel.js +0 -49
- package/src/connect.js +0 -6
- package/src/connect.node.js +0 -6
- package/src/index.node.d.ts +0 -10
- package/src/index.node.js +0 -3
- package/src/native-loader.js +0 -118
- package/src/node-advanced.d.ts +0 -27
- package/src/node-advanced.js +0 -1
- package/src/node-generated.d.ts +0 -24
- package/src/node-generated.js +0 -126
- package/src/node-index.d.ts +0 -1
- package/src/node-index.js +0 -7
- package/src/node.d.ts +0 -298
- package/src/node.js +0 -1456
- package/src/runtime.js +0 -66
- package/src/server-response.js +0 -49
- package/src/timer.js +0 -29
- package/src/webtransport.js +0 -639
package/src/channel.js
CHANGED
|
@@ -1,375 +1,210 @@
|
|
|
1
|
-
import { cancelled,
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
#
|
|
1
|
+
import { cancelled, statusFromTransportError, unavailable } from "./status.js";
|
|
2
|
+
|
|
3
|
+
/** WebTransport constructor options passed through unchanged. */
|
|
4
|
+
const WebTransportOptionKeys = [
|
|
5
|
+
"allowPooling",
|
|
6
|
+
"congestionControl",
|
|
7
|
+
"requireUnreliable",
|
|
8
|
+
"serverCertificateHashes",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const Backoff = Object.freeze({ initialDelayMs: 100, maxDelayMs: 30_000, jitter: 0.2 });
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A WebTransport session that reconnects in the background after it is lost. Calls open their
|
|
15
|
+
* streams on the current session and fail fast with Unavailable while it reconnects; a call that
|
|
16
|
+
* was in flight when the session was lost is never replayed.
|
|
17
|
+
*/
|
|
18
|
+
export class Channel {
|
|
19
|
+
#dial;
|
|
20
20
|
#onStateChange;
|
|
21
|
-
#
|
|
22
|
-
#state = "
|
|
23
|
-
#transport = null;
|
|
21
|
+
#session;
|
|
22
|
+
#state = "ready";
|
|
24
23
|
#waiters = [];
|
|
24
|
+
#stopped = new AbortController();
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
this.#isConnectionFailure = isConnectionFailure;
|
|
31
|
-
this.#onStateChange = options.onStateChange;
|
|
32
|
-
this.#options = normalizeChannelOptions(options);
|
|
33
|
-
queueMicrotask(() => this.#runConnectLoop(false));
|
|
26
|
+
/** Opens a channel and waits for its first session; use connect() instead. */
|
|
27
|
+
static async connect(url, options = {}) {
|
|
28
|
+
const dial = (signal) => dialSession(url, options, signal);
|
|
29
|
+
return new Channel(await dial(options.signal), dial, options.onStateChange);
|
|
34
30
|
}
|
|
35
31
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
constructor(session, dial, onStateChange) {
|
|
33
|
+
this.#dial = dial;
|
|
34
|
+
this.#onStateChange = onStateChange;
|
|
35
|
+
this.#adopt(session);
|
|
39
36
|
}
|
|
40
37
|
|
|
41
|
-
/**
|
|
38
|
+
/** "ready", "reconnecting" while the session is being re-established, or "closed". */
|
|
42
39
|
get state() {
|
|
43
40
|
return this.#state;
|
|
44
41
|
}
|
|
45
42
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
/** Opens a bidirectional stream on the current session. */
|
|
44
|
+
createBidirectionalStream() {
|
|
45
|
+
if (this.#state !== "ready") {
|
|
46
|
+
return Promise.reject(unavailable(`channel is ${this.#state}`));
|
|
47
|
+
}
|
|
48
|
+
return this.#session.createBidirectionalStream();
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/** Resolves once the channel is ready, immediately if it already is. */
|
|
52
52
|
waitUntilReady() {
|
|
53
|
-
if (this
|
|
53
|
+
if (this.#state === "ready") {
|
|
54
54
|
return Promise.resolve();
|
|
55
55
|
}
|
|
56
56
|
if (this.#state === "closed") {
|
|
57
57
|
return Promise.reject(unavailable("channel is closed"));
|
|
58
58
|
}
|
|
59
|
-
|
|
60
|
-
return new Promise((resolve, reject) => {
|
|
61
|
-
this.#waiters.push({ resolve, reject });
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Sends a unary call on the current generation without replaying it. */
|
|
66
|
-
async call(request, options = {}) {
|
|
67
|
-
const transport = this.#readyTransport();
|
|
68
|
-
try {
|
|
69
|
-
return await transport.call(request, options);
|
|
70
|
-
} catch (error) {
|
|
71
|
-
this.#operationFailed(transport, error);
|
|
72
|
-
throw error;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Starts a streaming call on the current generation without resuming it later. */
|
|
77
|
-
async streamingCall(request, requestBody, options = {}) {
|
|
78
|
-
const transport = this.#readyTransport();
|
|
79
|
-
try {
|
|
80
|
-
const stream = await transport.streamingCall(request, requestBody, options);
|
|
81
|
-
return observedStream(stream, (error) => this.#operationFailed(transport, error));
|
|
82
|
-
} catch (error) {
|
|
83
|
-
this.#operationFailed(transport, error);
|
|
84
|
-
throw error;
|
|
85
|
-
}
|
|
59
|
+
return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject }));
|
|
86
60
|
}
|
|
87
61
|
|
|
88
|
-
/** Stops reconnecting and closes the current
|
|
89
|
-
close(closeInfo
|
|
62
|
+
/** Stops reconnecting and closes the current session. */
|
|
63
|
+
close(closeInfo) {
|
|
90
64
|
if (this.#state === "closed") {
|
|
91
65
|
return;
|
|
92
66
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
this.#
|
|
96
|
-
this.#connectAbort?.abort();
|
|
97
|
-
this.#connectAbort = null;
|
|
67
|
+
const session = this.#session;
|
|
68
|
+
this.#session = undefined;
|
|
69
|
+
this.#stopped.abort();
|
|
98
70
|
this.#transition("closed");
|
|
99
|
-
this.#
|
|
100
|
-
|
|
71
|
+
for (const waiter of this.#waiters.splice(0)) {
|
|
72
|
+
waiter.reject(unavailable("channel is closed"));
|
|
73
|
+
}
|
|
74
|
+
closeQuietly(session, closeInfo);
|
|
101
75
|
}
|
|
102
76
|
|
|
103
|
-
#
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return this.#transport;
|
|
77
|
+
#adopt(session) {
|
|
78
|
+
this.#session = session;
|
|
79
|
+
const lost = (reason) => this.#lost(session, reason);
|
|
80
|
+
session.closed.then(lost, lost);
|
|
108
81
|
}
|
|
109
82
|
|
|
110
|
-
|
|
111
|
-
if (
|
|
83
|
+
#lost(session, reason) {
|
|
84
|
+
if (session !== this.#session) {
|
|
112
85
|
return;
|
|
113
86
|
}
|
|
87
|
+
this.#session = undefined;
|
|
88
|
+
closeQuietly(session);
|
|
89
|
+
void this.#reconnect(reason);
|
|
90
|
+
}
|
|
114
91
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
let attempt =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const delayMs = delayed
|
|
124
|
-
? reconnectDelay(this.#options, reconnecting ? attempt - 1 : attempt - 2)
|
|
125
|
-
: 0;
|
|
126
|
-
this.#transition(reconnecting ? "reconnecting" : "connecting", {
|
|
127
|
-
attempt,
|
|
128
|
-
delayMs,
|
|
129
|
-
error,
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
if (delayed) {
|
|
133
|
-
await waitForDelay(delayMs, controller.signal);
|
|
134
|
-
if (controller.signal.aborted) {
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
92
|
+
async #reconnect(error) {
|
|
93
|
+
const { signal } = this.#stopped;
|
|
94
|
+
for (let attempt = 1; !signal.aborted; attempt += 1) {
|
|
95
|
+
const delayMs = backoffDelay(attempt);
|
|
96
|
+
this.#transition("reconnecting", { attempt, delayMs, error });
|
|
97
|
+
await sleep(delayMs, signal);
|
|
98
|
+
if (signal.aborted) {
|
|
99
|
+
return;
|
|
137
100
|
}
|
|
138
101
|
|
|
139
|
-
let transport;
|
|
140
102
|
try {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
103
|
+
const session = await this.#dial(signal);
|
|
104
|
+
if (signal.aborted) {
|
|
105
|
+
closeQuietly(session);
|
|
144
106
|
return;
|
|
145
107
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
safeClose(transport);
|
|
108
|
+
this.#adopt(session);
|
|
109
|
+
this.#transition("ready", { attempt });
|
|
110
|
+
for (const waiter of this.#waiters.splice(0)) {
|
|
111
|
+
waiter.resolve();
|
|
112
|
+
}
|
|
152
113
|
return;
|
|
114
|
+
} catch (dialError) {
|
|
115
|
+
error = dialError;
|
|
153
116
|
}
|
|
154
|
-
|
|
155
|
-
this.#connectAbort = null;
|
|
156
|
-
this.#transport = transport;
|
|
157
|
-
this.#generation += 1;
|
|
158
|
-
this.#transition("ready", { attempt });
|
|
159
|
-
this.#resolveWaiters();
|
|
160
|
-
this.#watchTransport(transport);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
#watchTransport(transport) {
|
|
166
|
-
const closed = this.#observeClosed?.(transport);
|
|
167
|
-
if (closed == null || typeof closed.then !== "function") {
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
Promise.resolve(closed).then(
|
|
172
|
-
(closeInfo) => this.#transportFailed(transport, closeInfo),
|
|
173
|
-
(error) => this.#transportFailed(transport, error),
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
#transportFailed(transport, error) {
|
|
178
|
-
if (this.#state !== "ready" || transport !== this.#transport) {
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
this.#transport = null;
|
|
183
|
-
safeClose(transport);
|
|
184
|
-
void this.#runConnectLoop(true, error);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
#operationFailed(transport, error) {
|
|
188
|
-
if (this.#isConnectionFailure(error)) {
|
|
189
|
-
this.#transportFailed(transport, error);
|
|
190
117
|
}
|
|
191
118
|
}
|
|
192
119
|
|
|
193
120
|
#transition(state, extra = {}) {
|
|
194
121
|
const previousState = this.#state;
|
|
195
122
|
this.#state = state;
|
|
196
|
-
|
|
197
|
-
state,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
...extra,
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
if (typeof this.#onStateChange === "function") {
|
|
204
|
-
try {
|
|
205
|
-
this.#onStateChange(detail);
|
|
206
|
-
} catch (error) {
|
|
207
|
-
globalThis.reportError?.(error);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
this.dispatchEvent(lifecycleEvent("statechange", detail));
|
|
211
|
-
this.dispatchEvent(lifecycleEvent(state, detail));
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
#resolveWaiters() {
|
|
215
|
-
for (const waiter of this.#waiters.splice(0)) {
|
|
216
|
-
waiter.resolve();
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
#rejectWaiters(error) {
|
|
221
|
-
for (const waiter of this.#waiters.splice(0)) {
|
|
222
|
-
waiter.reject(error);
|
|
123
|
+
try {
|
|
124
|
+
this.#onStateChange?.({ state, previousState, ...extra });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
globalThis.reportError?.(error);
|
|
223
127
|
}
|
|
224
128
|
}
|
|
225
129
|
}
|
|
226
130
|
|
|
227
|
-
/**
|
|
228
|
-
export function
|
|
229
|
-
return
|
|
131
|
+
/** Opens a channel to a WebTransport server and waits for its session to be ready. */
|
|
132
|
+
export function connect(url, options = {}) {
|
|
133
|
+
return Channel.connect(url, options);
|
|
230
134
|
}
|
|
231
135
|
|
|
232
|
-
/**
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
if (
|
|
236
|
-
|
|
237
|
-
throw new TypeError("timeoutMs must be a non-negative finite number");
|
|
136
|
+
/** Establishes a ready WebTransport session; aborting the signal abandons the attempt. */
|
|
137
|
+
async function dialSession(url, options, signal) {
|
|
138
|
+
const WebTransportConstructor = options.WebTransport ?? globalThis.WebTransport;
|
|
139
|
+
if (typeof WebTransportConstructor !== "function") {
|
|
140
|
+
throw unavailable("WebTransport is not available in this JavaScript runtime");
|
|
238
141
|
}
|
|
239
|
-
|
|
240
|
-
const signal = options.signal;
|
|
241
142
|
if (signal?.aborted) {
|
|
242
|
-
|
|
243
|
-
throw initialAbortError(signal);
|
|
244
|
-
}
|
|
245
|
-
if (timeoutMs === 0) {
|
|
246
|
-
channel.close();
|
|
247
|
-
throw deadlineExceeded("initial connection deadline exceeded");
|
|
143
|
+
throw abortError(signal);
|
|
248
144
|
}
|
|
249
145
|
|
|
250
|
-
const
|
|
251
|
-
|
|
146
|
+
const session = new WebTransportConstructor(url, transportOptions(options));
|
|
147
|
+
const abandon = () => closeQuietly(session);
|
|
148
|
+
signal?.addEventListener("abort", abandon, { once: true });
|
|
252
149
|
try {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
signal.addEventListener("abort", rejectCancelled, { once: true });
|
|
263
|
-
removeAbort = () => signal.removeEventListener("abort", rejectCancelled);
|
|
264
|
-
}),
|
|
265
|
-
);
|
|
266
|
-
}
|
|
267
|
-
if (timeoutMs != null) {
|
|
268
|
-
waits.push(
|
|
269
|
-
waitForDelay(timeoutMs, timeoutAbort.signal).then(() => {
|
|
270
|
-
throw deadlineExceeded("initial connection deadline exceeded");
|
|
271
|
-
}),
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
await Promise.race(waits);
|
|
150
|
+
// A session that fails to connect settles both promises; one that closes first never becomes
|
|
151
|
+
// ready, so readiness must not wait for it.
|
|
152
|
+
await Promise.race([
|
|
153
|
+
session.ready,
|
|
154
|
+
session.closed.then(() => {
|
|
155
|
+
throw unavailable("WebTransport session closed before becoming ready");
|
|
156
|
+
}),
|
|
157
|
+
]);
|
|
275
158
|
} catch (error) {
|
|
276
|
-
|
|
277
|
-
throw error;
|
|
159
|
+
closeQuietly(session);
|
|
160
|
+
throw signal?.aborted ? abortError(signal) : statusFromTransportError(error);
|
|
278
161
|
} finally {
|
|
279
|
-
|
|
280
|
-
removeAbort();
|
|
162
|
+
signal?.removeEventListener("abort", abandon);
|
|
281
163
|
}
|
|
164
|
+
if (signal?.aborted) {
|
|
165
|
+
closeQuietly(session);
|
|
166
|
+
throw abortError(signal);
|
|
167
|
+
}
|
|
168
|
+
return session;
|
|
282
169
|
}
|
|
283
170
|
|
|
284
|
-
function
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (signal.reason?.name === "TrevRpcError") {
|
|
291
|
-
return signal.reason;
|
|
292
|
-
}
|
|
293
|
-
const error = cancelled("initial connection cancelled");
|
|
294
|
-
if (signal.reason !== undefined) {
|
|
295
|
-
Object.defineProperty(error, "cause", { configurable: true, value: signal.reason });
|
|
171
|
+
function transportOptions(options) {
|
|
172
|
+
const picked = {};
|
|
173
|
+
for (const key of WebTransportOptionKeys) {
|
|
174
|
+
if (options[key] !== undefined) {
|
|
175
|
+
picked[key] = options[key];
|
|
176
|
+
}
|
|
296
177
|
}
|
|
297
|
-
return
|
|
178
|
+
return picked;
|
|
298
179
|
}
|
|
299
180
|
|
|
300
|
-
function
|
|
301
|
-
|
|
302
|
-
const base = Math.min(options.maxDelayMs, exponential);
|
|
303
|
-
const jitter = 1 + (Math.random() * 2 - 1) * options.jitter;
|
|
304
|
-
return Math.min(options.maxDelayMs, Math.max(0, base * jitter));
|
|
181
|
+
function abortError(signal) {
|
|
182
|
+
return cancelled("connection cancelled", signal.reason);
|
|
305
183
|
}
|
|
306
184
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
remaining -= chunk;
|
|
313
|
-
}
|
|
185
|
+
/** Exponential backoff with jitter, capped at maxDelayMs. */
|
|
186
|
+
function backoffDelay(attempt) {
|
|
187
|
+
const { initialDelayMs, maxDelayMs, jitter } = Backoff;
|
|
188
|
+
const base = Math.min(maxDelayMs, initialDelayMs * 2 ** (attempt - 1));
|
|
189
|
+
return Math.min(maxDelayMs, base * (1 + (Math.random() * 2 - 1) * jitter));
|
|
314
190
|
}
|
|
315
191
|
|
|
316
|
-
function
|
|
192
|
+
function sleep(delayMs, signal) {
|
|
317
193
|
return new Promise((resolve) => {
|
|
318
|
-
const
|
|
319
|
-
signal.addEventListener("abort", done, { once: true });
|
|
320
|
-
|
|
321
|
-
function done() {
|
|
194
|
+
const done = () => {
|
|
322
195
|
clearTimeout(timer);
|
|
323
196
|
signal.removeEventListener("abort", done);
|
|
324
197
|
resolve();
|
|
325
|
-
}
|
|
198
|
+
};
|
|
199
|
+
const timer = setTimeout(done, delayMs);
|
|
200
|
+
signal.addEventListener("abort", done, { once: true });
|
|
326
201
|
});
|
|
327
202
|
}
|
|
328
203
|
|
|
329
|
-
function
|
|
330
|
-
const event = new Event(type);
|
|
331
|
-
Object.defineProperty(event, "detail", { value: detail });
|
|
332
|
-
return event;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function observedStream(stream, onFailure) {
|
|
336
|
-
const iterator = stream[Symbol.asyncIterator]();
|
|
337
|
-
const observed = {
|
|
338
|
-
[Symbol.asyncIterator]() {
|
|
339
|
-
return this;
|
|
340
|
-
},
|
|
341
|
-
next(...args) {
|
|
342
|
-
return observeResult(() => iterator.next(...args), onFailure);
|
|
343
|
-
},
|
|
344
|
-
};
|
|
345
|
-
|
|
346
|
-
for (const method of ["nextBatch", "nextBodyBatch"]) {
|
|
347
|
-
if (typeof iterator[method] === "function") {
|
|
348
|
-
observed[method] = (...args) => observeResult(() => iterator[method](...args), onFailure);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
if (typeof iterator.return === "function") {
|
|
352
|
-
observed.return = (...args) => iterator.return(...args);
|
|
353
|
-
}
|
|
354
|
-
if (typeof iterator.throw === "function") {
|
|
355
|
-
observed.throw = (...args) => iterator.throw(...args);
|
|
356
|
-
}
|
|
357
|
-
return observed;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
async function observeResult(operation, onFailure) {
|
|
361
|
-
try {
|
|
362
|
-
return await operation();
|
|
363
|
-
} catch (error) {
|
|
364
|
-
onFailure(error);
|
|
365
|
-
throw error;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function safeClose(transport, closeInfo) {
|
|
204
|
+
function closeQuietly(session, closeInfo) {
|
|
370
205
|
try {
|
|
371
|
-
|
|
206
|
+
session?.close(closeInfo);
|
|
372
207
|
} catch {
|
|
373
|
-
// The
|
|
208
|
+
// The session may already be gone.
|
|
374
209
|
}
|
|
375
210
|
}
|