@fluidframework/odsp-driver 2.21.0 → 2.22.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/CHANGELOG.md +12 -0
- package/README.md +1 -0
- package/dist/fetch.d.ts +5 -1
- package/dist/fetch.d.ts.map +1 -1
- package/dist/fetch.js +5 -9
- package/dist/fetch.js.map +1 -1
- package/dist/fetchSnapshot.d.ts +1 -1
- package/dist/fetchSnapshot.d.ts.map +1 -1
- package/dist/fetchSnapshot.js +8 -5
- package/dist/fetchSnapshot.js.map +1 -1
- package/dist/getFileLink.d.ts +1 -1
- package/dist/getFileLink.d.ts.map +1 -1
- package/dist/getFileLink.js +3 -3
- package/dist/getFileLink.js.map +1 -1
- package/dist/mockify.d.ts +60 -0
- package/dist/mockify.d.ts.map +1 -0
- package/dist/mockify.js +61 -0
- package/dist/mockify.js.map +1 -0
- package/dist/odspUtils.d.ts +3 -0
- package/dist/odspUtils.d.ts.map +1 -1
- package/dist/odspUtils.js +3 -3
- package/dist/odspUtils.js.map +1 -1
- package/dist/packageVersion.d.ts +1 -1
- package/dist/packageVersion.js +1 -1
- package/dist/packageVersion.js.map +1 -1
- package/dist/socketModule.d.ts +2 -1
- package/dist/socketModule.d.ts.map +1 -1
- package/dist/socketModule.js +2 -3
- package/dist/socketModule.js.map +1 -1
- package/dist/vroom.d.ts +1 -1
- package/dist/vroom.d.ts.map +1 -1
- package/dist/vroom.js +3 -3
- package/dist/vroom.js.map +1 -1
- package/lib/fetch.d.ts +5 -1
- package/lib/fetch.d.ts.map +1 -1
- package/lib/fetch.js +5 -5
- package/lib/fetch.js.map +1 -1
- package/lib/fetchSnapshot.d.ts +1 -1
- package/lib/fetchSnapshot.d.ts.map +1 -1
- package/lib/fetchSnapshot.js +8 -4
- package/lib/fetchSnapshot.js.map +1 -1
- package/lib/getFileLink.d.ts +1 -1
- package/lib/getFileLink.d.ts.map +1 -1
- package/lib/getFileLink.js +3 -2
- package/lib/getFileLink.js.map +1 -1
- package/lib/mockify.d.ts +60 -0
- package/lib/mockify.d.ts.map +1 -0
- package/lib/mockify.js +57 -0
- package/lib/mockify.js.map +1 -0
- package/lib/odspUtils.d.ts +3 -0
- package/lib/odspUtils.d.ts.map +1 -1
- package/lib/odspUtils.js +2 -2
- package/lib/odspUtils.js.map +1 -1
- package/lib/packageVersion.d.ts +1 -1
- package/lib/packageVersion.js +1 -1
- package/lib/packageVersion.js.map +1 -1
- package/lib/socketModule.d.ts +2 -1
- package/lib/socketModule.d.ts.map +1 -1
- package/lib/socketModule.js +2 -3
- package/lib/socketModule.js.map +1 -1
- package/lib/vroom.d.ts +1 -1
- package/lib/vroom.d.ts.map +1 -1
- package/lib/vroom.js +3 -2
- package/lib/vroom.js.map +1 -1
- package/package.json +15 -17
- package/src/fetch.ts +5 -11
- package/src/fetchSnapshot.ts +79 -73
- package/src/getFileLink.ts +56 -53
- package/src/mockify.ts +67 -0
- package/src/odspUtils.ts +3 -3
- package/src/packageVersion.ts +1 -1
- package/src/socketModule.ts +3 -3
- package/src/vroom.ts +92 -89
package/src/getFileLink.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "@fluidframework/telemetry-utils/internal";
|
|
21
21
|
|
|
22
22
|
import { getHeadersWithAuth } from "./getUrlAndHeadersWithAuth.js";
|
|
23
|
+
import { mockify } from "./mockify.js";
|
|
23
24
|
import {
|
|
24
25
|
fetchHelper,
|
|
25
26
|
getWithRetryForTokenRefresh,
|
|
@@ -42,64 +43,66 @@ const fileLinkCache = new Map<string, Promise<string>>();
|
|
|
42
43
|
* @param logger - used to log results of operation, including any error
|
|
43
44
|
* @returns Promise which resolves to file link url when successful; otherwise, undefined.
|
|
44
45
|
*/
|
|
45
|
-
export
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
46
|
+
export const getFileLink = mockify(
|
|
47
|
+
async (
|
|
48
|
+
getToken: TokenFetcher<OdspResourceTokenFetchOptions>,
|
|
49
|
+
resolvedUrl: IOdspResolvedUrl,
|
|
50
|
+
logger: ITelemetryLoggerExt,
|
|
51
|
+
): Promise<string> => {
|
|
52
|
+
const cacheKey = `${resolvedUrl.siteUrl}_${resolvedUrl.driveId}_${resolvedUrl.itemId}`;
|
|
53
|
+
const maybeFileLinkCacheEntry = fileLinkCache.get(cacheKey);
|
|
54
|
+
if (maybeFileLinkCacheEntry !== undefined) {
|
|
55
|
+
return maybeFileLinkCacheEntry;
|
|
56
|
+
}
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
58
|
+
const fileLinkGenerator = async function (): Promise<string> {
|
|
59
|
+
let fileLinkCore: string;
|
|
60
|
+
try {
|
|
61
|
+
let retryCount = 0;
|
|
62
|
+
fileLinkCore = await runWithRetry(
|
|
63
|
+
async () =>
|
|
64
|
+
runWithRetryForCoherencyAndServiceReadOnlyErrors(
|
|
65
|
+
async () =>
|
|
66
|
+
getFileLinkWithLocationRedirectionHandling(getToken, resolvedUrl, logger),
|
|
67
|
+
"getFileLinkCore",
|
|
68
|
+
logger,
|
|
69
|
+
),
|
|
70
|
+
"getShareLink",
|
|
71
|
+
logger,
|
|
72
|
+
{
|
|
73
|
+
// TODO: use a stronger type
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
75
|
+
onRetry(delayInMs: number, error: any) {
|
|
76
|
+
retryCount++;
|
|
77
|
+
if (retryCount === 5) {
|
|
78
|
+
if (error !== undefined && typeof error === "object") {
|
|
79
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
|
80
|
+
error.canRetry = false;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
79
83
|
throw error;
|
|
80
84
|
}
|
|
81
|
-
|
|
82
|
-
}
|
|
85
|
+
},
|
|
83
86
|
},
|
|
84
|
-
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
87
|
+
);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// Delete from the cache to permit retrying later.
|
|
90
|
+
fileLinkCache.delete(cacheKey);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
91
93
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
94
|
+
// We are guaranteed to run the getFileLinkCore at least once with successful result (which must be a string)
|
|
95
|
+
assert(
|
|
96
|
+
fileLinkCore !== undefined,
|
|
97
|
+
0x292 /* "Unexpected undefined result from getFileLinkCore" */,
|
|
98
|
+
);
|
|
99
|
+
return fileLinkCore;
|
|
100
|
+
};
|
|
101
|
+
const fileLink = fileLinkGenerator();
|
|
102
|
+
fileLinkCache.set(cacheKey, fileLink);
|
|
103
|
+
return fileLink;
|
|
104
|
+
},
|
|
105
|
+
);
|
|
103
106
|
|
|
104
107
|
/**
|
|
105
108
|
* Handles location redirection while fulfilling the getFileLink call. We don't want browser to handle
|
package/src/mockify.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
|
|
3
|
+
* Licensed under the MIT License.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A special key used to store the original function in a {@link Mockable | mockable} function.
|
|
8
|
+
* @remarks Use {@link mockify | `mockify.key`} as a convenient way to access this key.
|
|
9
|
+
*/
|
|
10
|
+
export const mockifyMockKey = Symbol("`mockify` mock function key");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A function that can be mocked after being decorated by {@link mockify | mockify()}.
|
|
14
|
+
*/
|
|
15
|
+
export interface Mockable<T extends (...args: any[]) => unknown> {
|
|
16
|
+
(...args: Parameters<T>): ReturnType<T>;
|
|
17
|
+
[mockifyMockKey]: T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Decorates a function to allow it to be mocked.
|
|
22
|
+
* @param fn - The function that will become mockable.
|
|
23
|
+
* @returns A function with a {@link mockifyMockKey | special property } that can be overwritten to mock the original function.
|
|
24
|
+
* By default, this property is set to the original function.
|
|
25
|
+
* If overwritten with a new function, the new function will be called instead of the original.
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* const original = () => console.log("original");
|
|
29
|
+
* const mockable = mockify(original);
|
|
30
|
+
* mockable(); // logs "original"
|
|
31
|
+
* mockable[mockify.key] = () => console.log("mocked");
|
|
32
|
+
* mockable(); // logs "mocked"
|
|
33
|
+
* mockable[mockify.key] = original;
|
|
34
|
+
* mockable(); // logs "original"
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* This pattern is useful for mocking top-level exported functions in a module.
|
|
38
|
+
* For example,
|
|
39
|
+
* ```typescript
|
|
40
|
+
* export function fn() { /* ... * / }
|
|
41
|
+
* ```
|
|
42
|
+
* becomes
|
|
43
|
+
* ```typescript
|
|
44
|
+
* import { mockify } from "./mockify.js";
|
|
45
|
+
* export const fn = mockify(() => { /* ... * / });
|
|
46
|
+
* ```
|
|
47
|
+
* and can now be mocked by another module that imports it.
|
|
48
|
+
* ```typescript
|
|
49
|
+
* import * as sinon from "sinon";
|
|
50
|
+
* import { mockify } from "./mockify.js";
|
|
51
|
+
* import { fn } from "./module.js";
|
|
52
|
+
* sinon.stub(fn, mockify.key).callsFake(() => {
|
|
53
|
+
* // ... mock function implementation ...
|
|
54
|
+
* });
|
|
55
|
+
* // ...
|
|
56
|
+
* sinon.restore();
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function mockify<T extends (...args: any[]) => unknown>(fn: T): Mockable<T> {
|
|
60
|
+
const mockable = (...args: Parameters<T>): ReturnType<T> => {
|
|
61
|
+
return mockable[mockifyMockKey](...args) as ReturnType<T>;
|
|
62
|
+
};
|
|
63
|
+
mockable[mockifyMockKey] = fn;
|
|
64
|
+
return mockable;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
mockify.key = mockifyMockKey;
|
package/src/odspUtils.ts
CHANGED
|
@@ -54,7 +54,6 @@ import {
|
|
|
54
54
|
wrapError,
|
|
55
55
|
} from "@fluidframework/telemetry-utils/internal";
|
|
56
56
|
|
|
57
|
-
import { fetch } from "./fetch.js";
|
|
58
57
|
import { storeLocatorInOdspUrl } from "./odspFluidFileLink.js";
|
|
59
58
|
// eslint-disable-next-line import/no-deprecated
|
|
60
59
|
import { ISnapshotContents } from "./odspPublicUtils.js";
|
|
@@ -137,10 +136,9 @@ export async function fetchHelper(
|
|
|
137
136
|
): Promise<IOdspResponse<Response>> {
|
|
138
137
|
const start = performanceNow();
|
|
139
138
|
|
|
140
|
-
// Node-fetch and dom have conflicting typing, force them to work by casting for now
|
|
141
139
|
return fetch(requestInfo, requestInit).then(
|
|
142
140
|
async (fetchResponse) => {
|
|
143
|
-
const response = fetchResponse
|
|
141
|
+
const response = fetchResponse;
|
|
144
142
|
// Let's assume we can retry.
|
|
145
143
|
if (!response) {
|
|
146
144
|
throw new NonRetryableError(
|
|
@@ -221,6 +219,8 @@ export async function fetchHelper(
|
|
|
221
219
|
},
|
|
222
220
|
);
|
|
223
221
|
}
|
|
222
|
+
// This allows `fetch` to be mocked (e.g. with sinon `stub()`)
|
|
223
|
+
fetchHelper.fetch = fetch;
|
|
224
224
|
|
|
225
225
|
/**
|
|
226
226
|
* A utility function to fetch and parse as JSON with support for retries
|
package/src/packageVersion.ts
CHANGED
package/src/socketModule.ts
CHANGED
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
|
|
6
6
|
import { io } from "socket.io-client";
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export const SocketIOClientStatic = io;
|
|
8
|
+
import { mockify, type Mockable } from "./mockify.js";
|
|
9
|
+
|
|
10
|
+
export const SocketIOClientStatic: Mockable<typeof io> = mockify(io);
|
package/src/vroom.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { v4 as uuid } from "uuid";
|
|
17
17
|
|
|
18
18
|
import { EpochTracker } from "./epochTracker.js";
|
|
19
|
+
import { mockify } from "./mockify.js";
|
|
19
20
|
import { getApiRoot } from "./odspUrlHelper.js";
|
|
20
21
|
import { TokenFetchOptionsEx } from "./odspUtils.js";
|
|
21
22
|
import { runWithRetry } from "./retryUtils.js";
|
|
@@ -42,100 +43,102 @@ interface IJoinSessionBody {
|
|
|
42
43
|
* This is optional and used only when collab session is being joined by client acting in app-only mode (i.e. without user context).
|
|
43
44
|
* If not specified client display name is extracted from the access token that is used to join session.
|
|
44
45
|
*/
|
|
45
|
-
export
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
46
|
+
export const fetchJoinSession = mockify(
|
|
47
|
+
async (
|
|
48
|
+
urlParts: IOdspUrlParts,
|
|
49
|
+
path: string,
|
|
50
|
+
method: "GET" | "POST",
|
|
51
|
+
logger: ITelemetryLoggerExt,
|
|
52
|
+
getAuthHeader: InstrumentedStorageTokenFetcher,
|
|
53
|
+
epochTracker: EpochTracker,
|
|
54
|
+
requestSocketToken: boolean,
|
|
55
|
+
options: TokenFetchOptionsEx,
|
|
56
|
+
disableJoinSessionRefresh: boolean | undefined,
|
|
57
|
+
isRefreshingJoinSession: boolean,
|
|
58
|
+
displayName: string | undefined,
|
|
59
|
+
): Promise<ISocketStorageDiscovery> => {
|
|
60
|
+
const apiRoot = getApiRoot(new URL(urlParts.siteUrl));
|
|
61
|
+
const url = `${apiRoot}/drives/${urlParts.driveId}/items/${urlParts.itemId}/${path}?ump=1`;
|
|
62
|
+
const authHeader = await getAuthHeader(
|
|
63
|
+
{ ...options, request: { url, method } },
|
|
64
|
+
"JoinSession",
|
|
65
|
+
);
|
|
64
66
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
...tokenRefreshProps,
|
|
72
|
-
refreshingSession: isRefreshingJoinSession,
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
return PerformanceEvent.timedExecAsync(
|
|
76
|
-
logger,
|
|
77
|
-
{
|
|
78
|
-
eventName: "JoinSession",
|
|
79
|
-
attempts: options.refresh ? 2 : 1,
|
|
80
|
-
details: JSON.stringify(details),
|
|
67
|
+
const tokenRefreshProps = options.refresh
|
|
68
|
+
? { hasClaims: !!options.claims, hasTenantId: !!options.tenantId }
|
|
69
|
+
: {};
|
|
70
|
+
const details: ITelemetryBaseProperties = {
|
|
71
|
+
refreshedToken: options.refresh,
|
|
72
|
+
requestSocketToken,
|
|
81
73
|
...tokenRefreshProps,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const formBoundary = uuid();
|
|
85
|
-
let postBody = `--${formBoundary}\r\n`;
|
|
86
|
-
postBody += `Authorization: ${authHeader}\r\n`;
|
|
87
|
-
postBody += `X-HTTP-Method-Override: POST\r\n`;
|
|
88
|
-
postBody += `Content-Type: application/json\r\n`;
|
|
89
|
-
if (!disableJoinSessionRefresh) {
|
|
90
|
-
postBody += `prefer: FluidRemoveCheckAccess\r\n`;
|
|
91
|
-
}
|
|
92
|
-
postBody += `_post: 1\r\n`;
|
|
74
|
+
refreshingSession: isRefreshingJoinSession,
|
|
75
|
+
};
|
|
93
76
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
77
|
+
return PerformanceEvent.timedExecAsync(
|
|
78
|
+
logger,
|
|
79
|
+
{
|
|
80
|
+
eventName: "JoinSession",
|
|
81
|
+
attempts: options.refresh ? 2 : 1,
|
|
82
|
+
details: JSON.stringify(details),
|
|
83
|
+
...tokenRefreshProps,
|
|
84
|
+
},
|
|
85
|
+
async (event) => {
|
|
86
|
+
const formBoundary = uuid();
|
|
87
|
+
let postBody = `--${formBoundary}\r\n`;
|
|
88
|
+
postBody += `Authorization: ${authHeader}\r\n`;
|
|
89
|
+
postBody += `X-HTTP-Method-Override: POST\r\n`;
|
|
90
|
+
postBody += `Content-Type: application/json\r\n`;
|
|
91
|
+
if (!disableJoinSessionRefresh) {
|
|
92
|
+
postBody += `prefer: FluidRemoveCheckAccess\r\n`;
|
|
93
|
+
}
|
|
94
|
+
postBody += `_post: 1\r\n`;
|
|
108
95
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
96
|
+
let requestBody: IJoinSessionBody | undefined;
|
|
97
|
+
if (requestSocketToken) {
|
|
98
|
+
requestBody = { ...requestBody, requestSocketToken: true };
|
|
99
|
+
}
|
|
100
|
+
if (displayName) {
|
|
101
|
+
requestBody = { ...requestBody, displayName };
|
|
102
|
+
}
|
|
103
|
+
if (requestBody) {
|
|
104
|
+
postBody += `\r\n${JSON.stringify(requestBody)}\r\n`;
|
|
105
|
+
}
|
|
106
|
+
postBody += `\r\n--${formBoundary}--`;
|
|
107
|
+
const headers: { [index: string]: string } = {
|
|
108
|
+
"Content-Type": `multipart/form-data;boundary=${formBoundary}`,
|
|
109
|
+
};
|
|
120
110
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
111
|
+
const response = await runWithRetry(
|
|
112
|
+
async () =>
|
|
113
|
+
epochTracker.fetchAndParseAsJSON<ISocketStorageDiscovery>(
|
|
114
|
+
url,
|
|
115
|
+
{ method, headers, body: postBody },
|
|
116
|
+
"joinSession",
|
|
117
|
+
true,
|
|
118
|
+
),
|
|
119
|
+
"joinSession",
|
|
120
|
+
logger,
|
|
121
|
+
);
|
|
124
122
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
// pushV2 websocket urls will contain pushf
|
|
129
|
-
pushv2: socketUrl.includes("pushf"),
|
|
130
|
-
webSocketHostName,
|
|
131
|
-
refreshSessionDurationSeconds: response.content.refreshSessionDurationSeconds,
|
|
132
|
-
});
|
|
123
|
+
const socketUrl = response.content.deltaStreamSocketUrl;
|
|
124
|
+
// expecting socketUrl to be something like https://{hostName}/...
|
|
125
|
+
const webSocketHostName = socketUrl.split("/")[2];
|
|
133
126
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
127
|
+
// TODO SPO-specific telemetry
|
|
128
|
+
event.end({
|
|
129
|
+
...response.propsToLog,
|
|
130
|
+
// pushV2 websocket urls will contain pushf
|
|
131
|
+
pushv2: socketUrl.includes("pushf"),
|
|
132
|
+
webSocketHostName,
|
|
133
|
+
refreshSessionDurationSeconds: response.content.refreshSessionDurationSeconds,
|
|
134
|
+
});
|
|
137
135
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
136
|
+
if (response.content.runtimeTenantId && !response.content.tenantId) {
|
|
137
|
+
response.content.tenantId = response.content.runtimeTenantId;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return response.content;
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
},
|
|
144
|
+
);
|