@beignet/next 0.0.3 → 0.0.4
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 +122 -0
- package/README.md +335 -113
- package/dist/index.d.ts +84 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +115 -16
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +260 -29
package/src/index.ts
CHANGED
|
@@ -7,9 +7,16 @@ import {
|
|
|
7
7
|
} from "@beignet/core/outbox";
|
|
8
8
|
import type { AnyPorts, StoragePort } from "@beignet/core/ports";
|
|
9
9
|
import type {
|
|
10
|
-
|
|
10
|
+
InferProviderPorts,
|
|
11
11
|
ServiceProvider,
|
|
12
12
|
} from "@beignet/core/providers";
|
|
13
|
+
import { resolveProviderInstrumentationPort } from "@beignet/core/providers";
|
|
14
|
+
import {
|
|
15
|
+
createInlineScheduleRunner,
|
|
16
|
+
type ScheduleDef,
|
|
17
|
+
type ScheduleInstrumentation,
|
|
18
|
+
type StandardSchema,
|
|
19
|
+
} from "@beignet/core/schedules";
|
|
13
20
|
import type {
|
|
14
21
|
ContractLike,
|
|
15
22
|
CreateServerOptions,
|
|
@@ -17,6 +24,7 @@ import type {
|
|
|
17
24
|
HttpRequestLike,
|
|
18
25
|
ResolveContract,
|
|
19
26
|
RouteDef,
|
|
27
|
+
ServerInstance,
|
|
20
28
|
StandardSchemaV1,
|
|
21
29
|
} from "@beignet/core/server";
|
|
22
30
|
import { createServer } from "@beignet/core/server";
|
|
@@ -27,11 +35,14 @@ import { createFetchHandler, toWebResponse } from "@beignet/web";
|
|
|
27
35
|
* Server types re-exported for Next.js apps.
|
|
28
36
|
*/
|
|
29
37
|
export type {
|
|
38
|
+
AnyUseCaseLike,
|
|
30
39
|
CreateServerOptions,
|
|
40
|
+
HandlerRouteDef,
|
|
31
41
|
RouteDef,
|
|
32
42
|
RouteGroup,
|
|
33
43
|
RouteHook,
|
|
34
44
|
ServerInstance,
|
|
45
|
+
UseCaseRouteDef,
|
|
35
46
|
} from "@beignet/core/server";
|
|
36
47
|
/**
|
|
37
48
|
* Server helpers re-exported for Next.js apps.
|
|
@@ -39,9 +50,9 @@ export type {
|
|
|
39
50
|
export {
|
|
40
51
|
contractsFromRoutes,
|
|
41
52
|
createServer,
|
|
53
|
+
defaultBinderInput,
|
|
42
54
|
defineRoute,
|
|
43
55
|
defineRouteGroup,
|
|
44
|
-
defineRouteHook,
|
|
45
56
|
defineRoutes,
|
|
46
57
|
} from "@beignet/core/server";
|
|
47
58
|
|
|
@@ -54,7 +65,11 @@ type AnyProvider = ServiceProvider<
|
|
|
54
65
|
unknown,
|
|
55
66
|
// biome-ignore lint/suspicious/noExplicitAny: provider config types are erased at this level
|
|
56
67
|
StandardSchemaV1<any, any>,
|
|
57
|
-
AnyPorts
|
|
68
|
+
AnyPorts,
|
|
69
|
+
// biome-ignore lint/suspicious/noExplicitAny: provider context types are erased at this level
|
|
70
|
+
any,
|
|
71
|
+
// biome-ignore lint/suspicious/noExplicitAny: provider service-input types are erased at this level
|
|
72
|
+
any
|
|
58
73
|
>;
|
|
59
74
|
|
|
60
75
|
/**
|
|
@@ -202,7 +217,7 @@ type OutboxDrainLogger = {
|
|
|
202
217
|
error(message: string, metadata?: Record<string, unknown>): void;
|
|
203
218
|
};
|
|
204
219
|
|
|
205
|
-
type
|
|
220
|
+
type OutboxDrainInstrumentation = {
|
|
206
221
|
record(event: {
|
|
207
222
|
type: "custom";
|
|
208
223
|
watcher: string;
|
|
@@ -220,7 +235,8 @@ type OutboxDrainPorts = {
|
|
|
220
235
|
eventBus?: DrainOutboxOptions["eventBus"];
|
|
221
236
|
jobs?: DrainOutboxOptions["jobs"];
|
|
222
237
|
logger?: OutboxDrainLogger;
|
|
223
|
-
devtools?:
|
|
238
|
+
devtools?: OutboxDrainInstrumentation;
|
|
239
|
+
instrumentation?: OutboxDrainInstrumentation;
|
|
224
240
|
};
|
|
225
241
|
|
|
226
242
|
type OutboxDrainContext = {
|
|
@@ -266,6 +282,59 @@ export type CreateOutboxDrainRouteOptions<Ctx extends OutboxDrainContext> = {
|
|
|
266
282
|
headers?: HeadersInit | ((req: Request) => HeadersInit | undefined);
|
|
267
283
|
};
|
|
268
284
|
|
|
285
|
+
type ScheduleRouteLogger = {
|
|
286
|
+
error(message: string, metadata?: Record<string, unknown>): void;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
type ScheduleRoutePorts = {
|
|
290
|
+
logger?: ScheduleRouteLogger;
|
|
291
|
+
devtools?: ScheduleInstrumentation;
|
|
292
|
+
instrumentation?: ScheduleInstrumentation;
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
type ScheduleRouteContext = {
|
|
296
|
+
requestId?: string;
|
|
297
|
+
traceId?: string;
|
|
298
|
+
ports: ScheduleRoutePorts;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Options for creating a serverless-safe schedule trigger route.
|
|
303
|
+
*/
|
|
304
|
+
export type CreateScheduleRouteOptions<Ctx extends ScheduleRouteContext> = {
|
|
305
|
+
/**
|
|
306
|
+
* Beignet Next server that owns app context and ports.
|
|
307
|
+
*/
|
|
308
|
+
server: Pick<NextServer<Ctx, Ctx["ports"]>, "createContextFromNext">;
|
|
309
|
+
/**
|
|
310
|
+
* Registered schedules, usually the `schedules` array from
|
|
311
|
+
* `server/schedules.ts`.
|
|
312
|
+
*/
|
|
313
|
+
schedules: readonly ScheduleDef<string, StandardSchema, Ctx>[];
|
|
314
|
+
/**
|
|
315
|
+
* Name of the schedule this route triggers. Unknown names throw when the
|
|
316
|
+
* route module loads so misconfigured routes fail at build or boot time.
|
|
317
|
+
*/
|
|
318
|
+
schedule: string;
|
|
319
|
+
/**
|
|
320
|
+
* Shared secret expected in the `Authorization: Bearer <secret>` header.
|
|
321
|
+
*
|
|
322
|
+
* Missing secrets fail closed with a 500 response so deployments do not
|
|
323
|
+
* accidentally expose an unauthenticated trigger route.
|
|
324
|
+
*/
|
|
325
|
+
secret?: string;
|
|
326
|
+
/**
|
|
327
|
+
* Run source label recorded on run metadata and devtools events.
|
|
328
|
+
*
|
|
329
|
+
* @default "next-cron-route"
|
|
330
|
+
*/
|
|
331
|
+
source?: string;
|
|
332
|
+
/**
|
|
333
|
+
* Additional response headers.
|
|
334
|
+
*/
|
|
335
|
+
headers?: HeadersInit | ((req: Request) => HeadersInit | undefined);
|
|
336
|
+
};
|
|
337
|
+
|
|
269
338
|
type UploadRouteContext = {
|
|
270
339
|
params?:
|
|
271
340
|
| Promise<Record<string, string | string[] | undefined>>
|
|
@@ -275,7 +344,11 @@ type UploadRouteContext = {
|
|
|
275
344
|
/**
|
|
276
345
|
* Beignet server adapted for Next.js route handlers and server components.
|
|
277
346
|
*/
|
|
278
|
-
export interface NextServer<
|
|
347
|
+
export interface NextServer<
|
|
348
|
+
Ctx,
|
|
349
|
+
Ports extends AnyPorts = AnyPorts,
|
|
350
|
+
ServiceInput = void,
|
|
351
|
+
> {
|
|
279
352
|
/**
|
|
280
353
|
* Catch-all Next.js route handler.
|
|
281
354
|
*/
|
|
@@ -294,6 +367,25 @@ export interface NextServer<Ctx, Ports extends AnyPorts = AnyPorts> {
|
|
|
294
367
|
* Create app context from `next/headers` for Server Components.
|
|
295
368
|
*/
|
|
296
369
|
createContextFromNext: () => Promise<Ctx>;
|
|
370
|
+
/**
|
|
371
|
+
* Build a fully assembled request context from a framework-neutral request.
|
|
372
|
+
*/
|
|
373
|
+
createRequestContext: ServerInstance<
|
|
374
|
+
Ctx,
|
|
375
|
+
Ports,
|
|
376
|
+
ServiceInput
|
|
377
|
+
>["createRequestContext"];
|
|
378
|
+
/**
|
|
379
|
+
* Build a fully assembled service context for schedules, outbox drains,
|
|
380
|
+
* commands, and background work.
|
|
381
|
+
*
|
|
382
|
+
* Requires `context.service` to be declared in `createNextServer(...)`.
|
|
383
|
+
*/
|
|
384
|
+
createServiceContext: ServerInstance<
|
|
385
|
+
Ctx,
|
|
386
|
+
Ports,
|
|
387
|
+
ServiceInput
|
|
388
|
+
>["createServiceContext"];
|
|
297
389
|
/**
|
|
298
390
|
* Registered contract inputs.
|
|
299
391
|
*/
|
|
@@ -683,7 +775,7 @@ export function createUploadRoute(
|
|
|
683
775
|
};
|
|
684
776
|
}
|
|
685
777
|
|
|
686
|
-
function
|
|
778
|
+
function cronRouteJson(
|
|
687
779
|
body: unknown,
|
|
688
780
|
status: number,
|
|
689
781
|
headers: HeadersInit | undefined,
|
|
@@ -694,6 +786,39 @@ function outboxDrainJson(
|
|
|
694
786
|
});
|
|
695
787
|
}
|
|
696
788
|
|
|
789
|
+
/**
|
|
790
|
+
* Compare two strings without leaking length or prefix timing.
|
|
791
|
+
*
|
|
792
|
+
* Both values are hashed with SHA-256 so the XOR comparison always runs over
|
|
793
|
+
* fixed-size digests, which keeps the check edge-compatible and constant-time.
|
|
794
|
+
*/
|
|
795
|
+
async function timingSafeStringEqual(a: string, b: string): Promise<boolean> {
|
|
796
|
+
const encoder = new TextEncoder();
|
|
797
|
+
const [digestA, digestB] = await Promise.all([
|
|
798
|
+
crypto.subtle.digest("SHA-256", encoder.encode(a)),
|
|
799
|
+
crypto.subtle.digest("SHA-256", encoder.encode(b)),
|
|
800
|
+
]);
|
|
801
|
+
const bytesA = new Uint8Array(digestA);
|
|
802
|
+
const bytesB = new Uint8Array(digestB);
|
|
803
|
+
|
|
804
|
+
let mismatch = 0;
|
|
805
|
+
for (let index = 0; index < bytesA.length; index++) {
|
|
806
|
+
mismatch |= (bytesA[index] ?? 0) ^ (bytesB[index] ?? 0);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
return mismatch === 0;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function isAuthorizedCronRequest(
|
|
813
|
+
req: Request,
|
|
814
|
+
secret: string,
|
|
815
|
+
): Promise<boolean> {
|
|
816
|
+
return timingSafeStringEqual(
|
|
817
|
+
req.headers.get("authorization") ?? "",
|
|
818
|
+
`Bearer ${secret}`,
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
697
822
|
/**
|
|
698
823
|
* Create Next.js route handlers that drain one durable outbox batch.
|
|
699
824
|
*
|
|
@@ -712,7 +837,7 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
|
|
|
712
837
|
const secret = options.secret;
|
|
713
838
|
|
|
714
839
|
if (!secret) {
|
|
715
|
-
return
|
|
840
|
+
return cronRouteJson(
|
|
716
841
|
{
|
|
717
842
|
ok: false,
|
|
718
843
|
error: "CRON_SECRET is not configured.",
|
|
@@ -722,12 +847,8 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
|
|
|
722
847
|
);
|
|
723
848
|
}
|
|
724
849
|
|
|
725
|
-
if (
|
|
726
|
-
return
|
|
727
|
-
{ ok: false, error: "Unauthorized" },
|
|
728
|
-
401,
|
|
729
|
-
headers,
|
|
730
|
-
);
|
|
850
|
+
if (!(await isAuthorizedCronRequest(req, secret))) {
|
|
851
|
+
return cronRouteJson({ ok: false, error: "Unauthorized" }, 401, headers);
|
|
731
852
|
}
|
|
732
853
|
|
|
733
854
|
let ctx: Ctx | undefined;
|
|
@@ -743,7 +864,11 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
|
|
|
743
864
|
batchSize: options.batchSize,
|
|
744
865
|
leaseMs: options.leaseMs,
|
|
745
866
|
retryDelayMs: options.retryDelayMs,
|
|
746
|
-
instrumentation: drainCtx.ports
|
|
867
|
+
instrumentation: resolveProviderInstrumentationPort(drainCtx.ports),
|
|
868
|
+
instrumentationContext: {
|
|
869
|
+
requestId: drainCtx.requestId,
|
|
870
|
+
traceId: drainCtx.traceId,
|
|
871
|
+
},
|
|
747
872
|
onError(error, message) {
|
|
748
873
|
drainCtx.ports.logger?.error("Outbox delivery failed", {
|
|
749
874
|
error,
|
|
@@ -756,7 +881,7 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
|
|
|
756
881
|
});
|
|
757
882
|
|
|
758
883
|
try {
|
|
759
|
-
await drainCtx.ports
|
|
884
|
+
await resolveProviderInstrumentationPort(drainCtx.ports)?.record({
|
|
760
885
|
type: "custom",
|
|
761
886
|
watcher: "outbox",
|
|
762
887
|
name: "outbox.drain",
|
|
@@ -767,12 +892,15 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
|
|
|
767
892
|
details: result,
|
|
768
893
|
});
|
|
769
894
|
} catch (error) {
|
|
770
|
-
drainCtx.ports.logger?.error(
|
|
771
|
-
|
|
772
|
-
|
|
895
|
+
drainCtx.ports.logger?.error(
|
|
896
|
+
"Outbox drain instrumentation recording failed",
|
|
897
|
+
{
|
|
898
|
+
error,
|
|
899
|
+
},
|
|
900
|
+
);
|
|
773
901
|
}
|
|
774
902
|
|
|
775
|
-
return
|
|
903
|
+
return cronRouteJson(
|
|
776
904
|
{
|
|
777
905
|
ok: true,
|
|
778
906
|
result,
|
|
@@ -783,7 +911,7 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
|
|
|
783
911
|
} catch (error) {
|
|
784
912
|
ctx?.ports.logger?.error("Outbox drain failed", { error });
|
|
785
913
|
|
|
786
|
-
return
|
|
914
|
+
return cronRouteJson(
|
|
787
915
|
{
|
|
788
916
|
ok: false,
|
|
789
917
|
error: "Outbox drain failed.",
|
|
@@ -800,6 +928,105 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
|
|
|
800
928
|
};
|
|
801
929
|
}
|
|
802
930
|
|
|
931
|
+
/**
|
|
932
|
+
* Create Next.js route handlers that trigger one registered schedule.
|
|
933
|
+
*
|
|
934
|
+
* This helper is intended for serverless cron routes. It authenticates the
|
|
935
|
+
* caller with a timing-safe bearer comparison, creates app context, runs the
|
|
936
|
+
* schedule inline, and records `schedule` events through the resolved
|
|
937
|
+
* provider instrumentation port (`ports.instrumentation`, then
|
|
938
|
+
* `ports.devtools`) when one is installed.
|
|
939
|
+
*/
|
|
940
|
+
export function createScheduleRoute<Ctx extends ScheduleRouteContext>(
|
|
941
|
+
options: CreateScheduleRouteOptions<Ctx>,
|
|
942
|
+
): {
|
|
943
|
+
GET: (req: Request) => Promise<Response>;
|
|
944
|
+
POST: (req: Request) => Promise<Response>;
|
|
945
|
+
} {
|
|
946
|
+
const schedule = options.schedules.find(
|
|
947
|
+
(candidate) => candidate.name === options.schedule,
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
if (!schedule) {
|
|
951
|
+
const available = options.schedules
|
|
952
|
+
.map((candidate) => candidate.name)
|
|
953
|
+
.sort();
|
|
954
|
+
|
|
955
|
+
throw new Error(
|
|
956
|
+
available.length > 0
|
|
957
|
+
? `Unknown schedule "${options.schedule}". Available schedules: ${available.join(", ")}.`
|
|
958
|
+
: `Unknown schedule "${options.schedule}". No schedules were passed to createScheduleRoute(...).`,
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const runScheduleFromRequest = async (req: Request): Promise<Response> => {
|
|
963
|
+
const headers = resolveHeaders(options.headers, req);
|
|
964
|
+
const secret = options.secret;
|
|
965
|
+
|
|
966
|
+
if (!secret) {
|
|
967
|
+
return cronRouteJson(
|
|
968
|
+
{
|
|
969
|
+
ok: false,
|
|
970
|
+
error: "CRON_SECRET is not configured.",
|
|
971
|
+
scheduleName: schedule.name,
|
|
972
|
+
},
|
|
973
|
+
500,
|
|
974
|
+
headers,
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (!(await isAuthorizedCronRequest(req, secret))) {
|
|
979
|
+
return cronRouteJson({ ok: false, error: "Unauthorized" }, 401, headers);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
try {
|
|
983
|
+
const ctx = await options.server.createContextFromNext();
|
|
984
|
+
const runner = createInlineScheduleRunner<Ctx>({
|
|
985
|
+
ctx,
|
|
986
|
+
instrumentation: resolveProviderInstrumentationPort(ctx.ports),
|
|
987
|
+
instrumentationContext: {
|
|
988
|
+
requestId: ctx.requestId,
|
|
989
|
+
traceId: ctx.traceId,
|
|
990
|
+
},
|
|
991
|
+
onError({ error }) {
|
|
992
|
+
ctx.ports.logger?.error("Schedule failed", {
|
|
993
|
+
error,
|
|
994
|
+
scheduleName: schedule.name,
|
|
995
|
+
});
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
await runner.run(schedule, {
|
|
1000
|
+
source: options.source ?? "next-cron-route",
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
return cronRouteJson(
|
|
1004
|
+
{
|
|
1005
|
+
ok: true,
|
|
1006
|
+
scheduleName: schedule.name,
|
|
1007
|
+
},
|
|
1008
|
+
200,
|
|
1009
|
+
headers,
|
|
1010
|
+
);
|
|
1011
|
+
} catch {
|
|
1012
|
+
return cronRouteJson(
|
|
1013
|
+
{
|
|
1014
|
+
ok: false,
|
|
1015
|
+
error: "Schedule failed.",
|
|
1016
|
+
scheduleName: schedule.name,
|
|
1017
|
+
},
|
|
1018
|
+
500,
|
|
1019
|
+
headers,
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
return {
|
|
1025
|
+
GET: runScheduleFromRequest,
|
|
1026
|
+
POST: runScheduleFromRequest,
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
|
|
803
1030
|
/**
|
|
804
1031
|
* Create a Beignet server adapted for Next.js.
|
|
805
1032
|
*
|
|
@@ -810,11 +1037,14 @@ export function createOutboxDrainRoute<Ctx extends OutboxDrainContext>(
|
|
|
810
1037
|
export async function createNextServer<
|
|
811
1038
|
Ctx,
|
|
812
1039
|
Ports extends AnyPorts = AnyPorts,
|
|
1040
|
+
ServiceInput = void,
|
|
813
1041
|
Routes extends readonly RouteDef<Ctx>[] = readonly RouteDef<Ctx>[],
|
|
814
1042
|
Providers extends readonly AnyProvider[] = readonly AnyProvider[],
|
|
815
1043
|
>(
|
|
816
|
-
options: CreateServerOptions<Ctx, Ports, Routes, Providers>,
|
|
817
|
-
): Promise<
|
|
1044
|
+
options: CreateServerOptions<Ctx, Ports, ServiceInput, Routes, Providers>,
|
|
1045
|
+
): Promise<
|
|
1046
|
+
NextServer<Ctx, Ports & InferProviderPorts<Providers>, ServiceInput>
|
|
1047
|
+
> {
|
|
818
1048
|
const runtime = await createServer(options);
|
|
819
1049
|
|
|
820
1050
|
return {
|
|
@@ -823,14 +1053,16 @@ export async function createNextServer<
|
|
|
823
1053
|
handle: (fn) => createFetchHandler(runtime.route(contract).handle(fn)),
|
|
824
1054
|
}),
|
|
825
1055
|
createContextFromNext: async () => {
|
|
826
|
-
|
|
1056
|
+
// "next" ships no package exports map, so Node-style ESM resolution
|
|
1057
|
+
// (NodeNext) needs the explicit .js subpath. Bundlers resolve it the same.
|
|
1058
|
+
const { cookies, headers } = await import("next/headers.js");
|
|
827
1059
|
const h = await headers();
|
|
828
1060
|
const c = await cookies();
|
|
829
1061
|
|
|
830
1062
|
// Minimal Request-like object for Next.js Server Components.
|
|
831
1063
|
// Note: Server Components don't have a real HTTP URL; this is a synthetic placeholder.
|
|
832
1064
|
// Do not rely on `url` for routing, security, or network behavior.
|
|
833
|
-
// Extended with cookies property for easier access in
|
|
1065
|
+
// Extended with cookies property for easier access in context factories.
|
|
834
1066
|
// The json() and text() methods return empty values since there's no actual HTTP request body.
|
|
835
1067
|
const req: HttpRequestLike & {
|
|
836
1068
|
cookies?: { get: (key: string) => string | undefined };
|
|
@@ -851,11 +1083,10 @@ export async function createNextServer<
|
|
|
851
1083
|
},
|
|
852
1084
|
};
|
|
853
1085
|
|
|
854
|
-
return
|
|
855
|
-
req,
|
|
856
|
-
ports: runtime.ports,
|
|
857
|
-
});
|
|
1086
|
+
return runtime.createRequestContext(req);
|
|
858
1087
|
},
|
|
1088
|
+
createRequestContext: runtime.createRequestContext,
|
|
1089
|
+
createServiceContext: runtime.createServiceContext,
|
|
859
1090
|
contracts: runtime.contracts,
|
|
860
1091
|
stop: () => runtime.stop(),
|
|
861
1092
|
ports: runtime.ports,
|