akanjs 2.2.11 → 2.2.13-rc.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 +7 -0
- package/client/cookie.ts +3 -13
- package/client/router.ts +11 -3
- package/fetch/requestStorage.ts +106 -39
- package/package.json +1 -1
- package/server/akanApp.ts +55 -0
- package/server/hmr/clientScript.ts +2 -8
- package/server/rscClient.tsx +17 -7
- package/server/rscHttp.ts +7 -0
- package/server/rscWorker.tsx +183 -46
- package/server/rscWorkerHost.ts +242 -70
- package/server/rscWorkerReplay.ts +35 -0
- package/server/ssrFromRscRenderer.tsx +562 -81
- package/server/ssrTypes.ts +10 -0
- package/server/webRouter.ts +128 -28
- package/service/ipcTypes.ts +2 -0
- package/types/client/router.d.ts +8 -2
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/fetch/requestStorage.d.ts +31 -6
- package/types/server/hmr/clientScript.d.ts +1 -1
- package/types/server/rscClient.d.ts +3 -2
- package/types/server/rscHttp.d.ts +2 -0
- package/types/server/rscWorkerHost.d.ts +31 -0
- package/types/server/rscWorkerReplay.d.ts +23 -0
- package/types/server/ssrFromRscRenderer.d.ts +31 -1
- package/types/server/ssrTypes.d.ts +9 -0
- package/types/server/webRouter.d.ts +8 -1
- package/types/service/ipcTypes.d.ts +2 -0
- package/ui/Link/SsrLink.tsx +0 -2
package/server/rscWorkerHost.ts
CHANGED
|
@@ -5,24 +5,201 @@ import { type AkanI18nConfig, DEFAULT_AKAN_I18N, Logger } from "akanjs/common";
|
|
|
5
5
|
import type { AkanTheme } from "akanjs/fetch";
|
|
6
6
|
import type { AkanMetricsReport } from "akanjs/service";
|
|
7
7
|
import type { ClientManifest } from "./artifact";
|
|
8
|
+
import type { SsrLateRedirect } from "./ssrTypes";
|
|
8
9
|
import type { BaseBuildArtifact, CssAsset } from "./types";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
const DEFAULT_RSC_HOST_MAX_PENDING_CHUNKS = 256;
|
|
12
|
+
|
|
13
|
+
export interface RscPending {
|
|
11
14
|
onChunk: (data: Uint8Array) => void;
|
|
12
15
|
onEnd: () => void;
|
|
13
16
|
onError: (message: string) => void;
|
|
14
17
|
onMeta?: (meta: { theme?: AkanTheme; status?: number }) => void;
|
|
15
|
-
onRedirect?: (location: string, method: RscRedirectMethod) => void;
|
|
18
|
+
onRedirect?: (location: string, method: RscRedirectMethod, status: RscRedirectStatus) => void;
|
|
19
|
+
onLateRedirect?: (location: string, method: RscRedirectMethod, status: RscRedirectStatus) => void;
|
|
16
20
|
onNotFound?: () => void;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export type RscRedirectMethod = "replace" | "push";
|
|
24
|
+
export type RscRedirectStatus = 303 | 307 | 308;
|
|
20
25
|
|
|
21
26
|
export type RscRenderResult =
|
|
22
|
-
| {
|
|
23
|
-
|
|
27
|
+
| {
|
|
28
|
+
type: "stream";
|
|
29
|
+
stream: ReadableStream<Uint8Array>;
|
|
30
|
+
theme?: AkanTheme;
|
|
31
|
+
status?: number;
|
|
32
|
+
lateControl: Promise<SsrLateRedirect | null>;
|
|
33
|
+
cancel: (reason?: unknown) => void;
|
|
34
|
+
}
|
|
35
|
+
| { type: "redirect"; location: string; method: RscRedirectMethod; status: RscRedirectStatus }
|
|
24
36
|
| { type: "not-found" };
|
|
25
37
|
|
|
38
|
+
export function getRscHostMaxPendingChunks(value = process.env.AKAN_RSC_HOST_MAX_PENDING_CHUNKS): number {
|
|
39
|
+
const parsed = Number.parseInt(value ?? "", 10);
|
|
40
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_RSC_HOST_MAX_PENDING_CHUNKS;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function nextRscHostPendingChunkCount(currentPendingChunks: number, desiredSize: number | null): number {
|
|
44
|
+
return desiredSize !== null && desiredSize <= 0 ? currentPendingChunks + 1 : 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isRscHostPendingChunkOverflow(pendingChunks: number, maxPendingChunks: number): boolean {
|
|
48
|
+
return pendingChunks > maxPendingChunks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createRscRenderAbortError(reason?: unknown): Error {
|
|
52
|
+
if (reason instanceof Error) return reason;
|
|
53
|
+
const error = new Error(reason === undefined ? "rsc render aborted" : String(reason));
|
|
54
|
+
error.name = "AbortError";
|
|
55
|
+
return error;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createIdempotentRscRenderCancel(onCancel: (reason?: unknown) => void): (reason?: unknown) => void {
|
|
59
|
+
let cancelled = false;
|
|
60
|
+
return (reason?: unknown) => {
|
|
61
|
+
if (cancelled) return;
|
|
62
|
+
cancelled = true;
|
|
63
|
+
onCancel(reason);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createRscHostRenderStream(input: {
|
|
68
|
+
setPending: (pending: RscPending) => void;
|
|
69
|
+
deletePending: () => void;
|
|
70
|
+
sendRenderOrQueue: () => void;
|
|
71
|
+
cancelRender: (reason?: unknown) => void;
|
|
72
|
+
maxPendingChunks?: number;
|
|
73
|
+
signal?: AbortSignal;
|
|
74
|
+
onPendingChunkOverflow?: () => void;
|
|
75
|
+
}): Promise<RscRenderResult> {
|
|
76
|
+
let settled = false;
|
|
77
|
+
let stream!: ReadableStream<Uint8Array>;
|
|
78
|
+
let theme: AkanTheme | undefined;
|
|
79
|
+
let status: number | undefined;
|
|
80
|
+
let resolveLateControl!: (control: SsrLateRedirect | null) => void;
|
|
81
|
+
const lateControl = new Promise<SsrLateRedirect | null>((resolve) => {
|
|
82
|
+
resolveLateControl = resolve;
|
|
83
|
+
});
|
|
84
|
+
let lateControlSettled = false;
|
|
85
|
+
const settleLateControl = (control: SsrLateRedirect | null) => {
|
|
86
|
+
if (lateControlSettled) return;
|
|
87
|
+
lateControlSettled = true;
|
|
88
|
+
resolveLateControl(control);
|
|
89
|
+
};
|
|
90
|
+
const maxPendingChunks = input.maxPendingChunks ?? getRscHostMaxPendingChunks();
|
|
91
|
+
let pendingChunks = 0;
|
|
92
|
+
let removeAbortListener: (() => void) | undefined;
|
|
93
|
+
const cleanupAbortListener = () => {
|
|
94
|
+
removeAbortListener?.();
|
|
95
|
+
removeAbortListener = undefined;
|
|
96
|
+
};
|
|
97
|
+
const cancelRender = createIdempotentRscRenderCancel((reason) => {
|
|
98
|
+
input.deletePending();
|
|
99
|
+
settleLateControl(null);
|
|
100
|
+
input.cancelRender(reason);
|
|
101
|
+
cleanupAbortListener();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return new Promise<RscRenderResult>((resolve, reject) => {
|
|
105
|
+
stream = new ReadableStream<Uint8Array>({
|
|
106
|
+
start: (controller) => {
|
|
107
|
+
const abortRender = () => {
|
|
108
|
+
const error = createRscRenderAbortError(input.signal?.reason);
|
|
109
|
+
cancelRender(error);
|
|
110
|
+
if (!settled) {
|
|
111
|
+
settled = true;
|
|
112
|
+
reject(error);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
controller.error(error);
|
|
116
|
+
};
|
|
117
|
+
const settleStream = () => {
|
|
118
|
+
if (settled) return;
|
|
119
|
+
settled = true;
|
|
120
|
+
resolve({ type: "stream", stream, theme, status, lateControl, cancel: cancelRender });
|
|
121
|
+
};
|
|
122
|
+
input.setPending({
|
|
123
|
+
onMeta: (meta) => {
|
|
124
|
+
theme = meta.theme;
|
|
125
|
+
status = meta.status;
|
|
126
|
+
settleStream();
|
|
127
|
+
},
|
|
128
|
+
onChunk: (data) => {
|
|
129
|
+
settleStream();
|
|
130
|
+
pendingChunks = nextRscHostPendingChunkCount(pendingChunks, controller.desiredSize);
|
|
131
|
+
if (isRscHostPendingChunkOverflow(pendingChunks, maxPendingChunks)) {
|
|
132
|
+
const msg = `rsc worker host queue exceeded ${maxPendingChunks} pending chunks`;
|
|
133
|
+
const error = new Error(msg);
|
|
134
|
+
input.onPendingChunkOverflow?.();
|
|
135
|
+
cancelRender(error);
|
|
136
|
+
controller.error(error);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
controller.enqueue(data);
|
|
140
|
+
},
|
|
141
|
+
onEnd: () => {
|
|
142
|
+
settleLateControl(null);
|
|
143
|
+
cleanupAbortListener();
|
|
144
|
+
settleStream();
|
|
145
|
+
controller.close();
|
|
146
|
+
},
|
|
147
|
+
onError: (msg) => {
|
|
148
|
+
settleLateControl(null);
|
|
149
|
+
cleanupAbortListener();
|
|
150
|
+
if (!settled) {
|
|
151
|
+
settled = true;
|
|
152
|
+
reject(new Error(msg));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
controller.error(new Error(msg));
|
|
156
|
+
},
|
|
157
|
+
onRedirect: (location, method, status) => {
|
|
158
|
+
settleLateControl(null);
|
|
159
|
+
cleanupAbortListener();
|
|
160
|
+
if (!settled) {
|
|
161
|
+
settled = true;
|
|
162
|
+
resolve({ type: "redirect", location, method, status });
|
|
163
|
+
controller.close();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
controller.error(new Error(`redirect after stream started: ${location}`));
|
|
167
|
+
},
|
|
168
|
+
onLateRedirect: (location, method, status) => {
|
|
169
|
+
settleLateControl({ type: "redirect", location, method, status });
|
|
170
|
+
},
|
|
171
|
+
onNotFound: () => {
|
|
172
|
+
settleLateControl(null);
|
|
173
|
+
cleanupAbortListener();
|
|
174
|
+
if (!settled) {
|
|
175
|
+
settled = true;
|
|
176
|
+
resolve({ type: "not-found" });
|
|
177
|
+
controller.close();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
controller.error(new Error("not-found after stream started"));
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
if (input.signal) {
|
|
184
|
+
if (input.signal.aborted) {
|
|
185
|
+
abortRender();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
input.signal.addEventListener("abort", abortRender, { once: true });
|
|
189
|
+
removeAbortListener = () => input.signal?.removeEventListener("abort", abortRender);
|
|
190
|
+
}
|
|
191
|
+
input.sendRenderOrQueue();
|
|
192
|
+
},
|
|
193
|
+
cancel: (reason) => {
|
|
194
|
+
cancelRender(reason);
|
|
195
|
+
},
|
|
196
|
+
pull: () => {
|
|
197
|
+
pendingChunks = Math.max(0, pendingChunks - 1);
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
26
203
|
type RscInMsg =
|
|
27
204
|
| { type: "hello" }
|
|
28
205
|
| { type: "ready" }
|
|
@@ -30,7 +207,14 @@ type RscInMsg =
|
|
|
30
207
|
| { type: "meta"; requestId: string; theme?: AkanTheme; status?: number }
|
|
31
208
|
| { type: "chunk"; requestId: string; data: Uint8Array }
|
|
32
209
|
| { type: "end"; requestId: string }
|
|
33
|
-
| { type: "redirect"; requestId: string; location: string; method?: RscRedirectMethod }
|
|
210
|
+
| { type: "redirect"; requestId: string; location: string; method?: RscRedirectMethod; status?: RscRedirectStatus }
|
|
211
|
+
| {
|
|
212
|
+
type: "late-redirect";
|
|
213
|
+
requestId: string;
|
|
214
|
+
location: string;
|
|
215
|
+
method?: RscRedirectMethod;
|
|
216
|
+
status?: RscRedirectStatus;
|
|
217
|
+
}
|
|
34
218
|
| { type: "not-found"; requestId: string }
|
|
35
219
|
| { type: "metrics"; metrics: AkanMetricsReport }
|
|
36
220
|
| { type: "error"; requestId: string; message: string; buildId?: number };
|
|
@@ -105,6 +289,7 @@ export class RscWorker {
|
|
|
105
289
|
#recycleCount = 0;
|
|
106
290
|
#lastRecycleReason: string | undefined;
|
|
107
291
|
#lastWorkerMetrics: AkanMetricsReport = {};
|
|
292
|
+
#hostPendingChunkOverflowCount = 0;
|
|
108
293
|
#restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
109
294
|
#recycleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
110
295
|
#rollingRecycle: { oldProc: Bun.Subprocess<"ignore", "inherit", "inherit">; reason: string } | null = null;
|
|
@@ -171,73 +356,34 @@ export class RscWorker {
|
|
|
171
356
|
},
|
|
172
357
|
cancel: () => {
|
|
173
358
|
this.#pending.delete(requestId);
|
|
359
|
+
this.#cancelRender(requestId);
|
|
174
360
|
},
|
|
175
361
|
});
|
|
176
362
|
}
|
|
177
363
|
|
|
178
|
-
renderWithMeta(
|
|
364
|
+
renderWithMeta(
|
|
365
|
+
req: Request,
|
|
366
|
+
options: { clientManifest?: ClientManifest; signal?: AbortSignal } = {},
|
|
367
|
+
): Promise<RscRenderResult> {
|
|
179
368
|
const requestId = crypto.randomUUID();
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
},
|
|
198
|
-
onChunk: (data) => {
|
|
199
|
-
settleStream();
|
|
200
|
-
controller.enqueue(data);
|
|
201
|
-
},
|
|
202
|
-
onEnd: () => {
|
|
203
|
-
settleStream();
|
|
204
|
-
controller.close();
|
|
205
|
-
},
|
|
206
|
-
onError: (msg) => {
|
|
207
|
-
if (!settled) {
|
|
208
|
-
settled = true;
|
|
209
|
-
reject(new Error(msg));
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
controller.error(new Error(msg));
|
|
213
|
-
},
|
|
214
|
-
onRedirect: (location, method) => {
|
|
215
|
-
if (!settled) {
|
|
216
|
-
settled = true;
|
|
217
|
-
resolve({ type: "redirect", location, method });
|
|
218
|
-
controller.close();
|
|
219
|
-
return;
|
|
220
|
-
}
|
|
221
|
-
controller.error(new Error(`redirect after stream started: ${location}`));
|
|
222
|
-
},
|
|
223
|
-
onNotFound: () => {
|
|
224
|
-
if (!settled) {
|
|
225
|
-
settled = true;
|
|
226
|
-
resolve({ type: "not-found" });
|
|
227
|
-
controller.close();
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
controller.error(new Error("not-found after stream started"));
|
|
231
|
-
},
|
|
232
|
-
});
|
|
233
|
-
this.#sendRenderOrQueue(requestId, req, options.clientManifest);
|
|
234
|
-
},
|
|
235
|
-
cancel: () => {
|
|
236
|
-
this.#pending.delete(requestId);
|
|
237
|
-
},
|
|
238
|
-
});
|
|
369
|
+
return createRscHostRenderStream({
|
|
370
|
+
setPending: (pending) => {
|
|
371
|
+
this.#pending.set(requestId, pending);
|
|
372
|
+
},
|
|
373
|
+
deletePending: () => {
|
|
374
|
+
this.#pending.delete(requestId);
|
|
375
|
+
},
|
|
376
|
+
sendRenderOrQueue: () => {
|
|
377
|
+
this.#sendRenderOrQueue(requestId, req, options.clientManifest);
|
|
378
|
+
},
|
|
379
|
+
cancelRender: () => {
|
|
380
|
+
this.#cancelRender(requestId);
|
|
381
|
+
},
|
|
382
|
+
signal: options.signal,
|
|
383
|
+
onPendingChunkOverflow: () => {
|
|
384
|
+
this.#hostPendingChunkOverflowCount += 1;
|
|
385
|
+
},
|
|
239
386
|
});
|
|
240
|
-
return result;
|
|
241
387
|
}
|
|
242
388
|
|
|
243
389
|
kill(): void {
|
|
@@ -257,8 +403,18 @@ export class RscWorker {
|
|
|
257
403
|
}
|
|
258
404
|
|
|
259
405
|
getMetrics(): AkanMetricsReport {
|
|
260
|
-
|
|
261
|
-
|
|
406
|
+
const {
|
|
407
|
+
rscWorkerPid: _rscWorkerPid,
|
|
408
|
+
rscWorkerStatus: _rscWorkerStatus,
|
|
409
|
+
rscWorkerRestartCount: _rscWorkerRestartCount,
|
|
410
|
+
rscWorkerRecycleCount: _rscWorkerRecycleCount,
|
|
411
|
+
rscWorkerLastRecycleReason: _rscWorkerLastRecycleReason,
|
|
412
|
+
rscPendingRenderCount: _rscPendingRenderCount,
|
|
413
|
+
rscQueuedSendCount: _rscQueuedSendCount,
|
|
414
|
+
rscHostPendingChunkOverflowCount: _rscHostPendingChunkOverflowCount,
|
|
415
|
+
...workerMetrics
|
|
416
|
+
} = this.#lastWorkerMetrics;
|
|
417
|
+
return Object.assign(workerMetrics, {
|
|
262
418
|
rscWorkerPid: this.#proc.pid,
|
|
263
419
|
rscWorkerStatus: this.#status,
|
|
264
420
|
rscWorkerRestartCount: this.#restartCount,
|
|
@@ -266,7 +422,8 @@ export class RscWorker {
|
|
|
266
422
|
rscWorkerLastRecycleReason: this.#lastRecycleReason,
|
|
267
423
|
rscPendingRenderCount: this.#pending.size,
|
|
268
424
|
rscQueuedSendCount: this.#queuedSends.length,
|
|
269
|
-
|
|
425
|
+
rscHostPendingChunkOverflowCount: this.#hostPendingChunkOverflowCount,
|
|
426
|
+
});
|
|
270
427
|
}
|
|
271
428
|
|
|
272
429
|
restartWhenIdle(reason: string): boolean {
|
|
@@ -406,7 +563,14 @@ export class RscWorker {
|
|
|
406
563
|
this.#resolvePending(message.requestId, (p) => p.onEnd());
|
|
407
564
|
return;
|
|
408
565
|
case "redirect":
|
|
409
|
-
this.#resolvePending(message.requestId, (p) =>
|
|
566
|
+
this.#resolvePending(message.requestId, (p) =>
|
|
567
|
+
p.onRedirect?.(message.location, message.method ?? "replace", message.status ?? 307),
|
|
568
|
+
);
|
|
569
|
+
return;
|
|
570
|
+
case "late-redirect":
|
|
571
|
+
this.#pending
|
|
572
|
+
.get(message.requestId)
|
|
573
|
+
?.onLateRedirect?.(message.location, message.method ?? "replace", message.status ?? 307);
|
|
410
574
|
return;
|
|
411
575
|
case "not-found":
|
|
412
576
|
this.#resolvePending(message.requestId, (p) => p.onNotFound?.());
|
|
@@ -467,6 +631,14 @@ export class RscWorker {
|
|
|
467
631
|
}
|
|
468
632
|
}
|
|
469
633
|
|
|
634
|
+
#cancelRender(requestId: string): void {
|
|
635
|
+
if (this.#status !== "ready") return;
|
|
636
|
+
try {
|
|
637
|
+
this.#proc.send({ type: "cancel", requestId });
|
|
638
|
+
} catch {
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
470
642
|
#flushQueuedSends(): void {
|
|
471
643
|
const queue = this.#queuedSends;
|
|
472
644
|
this.#queuedSends = [];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type CachedRscReplayMessage =
|
|
2
|
+
| { type: "meta"; requestId: string; theme?: string; status?: number }
|
|
3
|
+
| { type: "chunk"; requestId: string; data: Uint8Array }
|
|
4
|
+
| { type: "end"; requestId: string };
|
|
5
|
+
|
|
6
|
+
function yieldToHostEventLoop(): Promise<void> {
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function replayCachedRscResult(input: {
|
|
11
|
+
requestId: string;
|
|
12
|
+
chunks: readonly Uint8Array[];
|
|
13
|
+
theme?: string;
|
|
14
|
+
status?: number;
|
|
15
|
+
send: (message: CachedRscReplayMessage) => void;
|
|
16
|
+
isCancelled: () => boolean;
|
|
17
|
+
yieldEveryChunks?: number;
|
|
18
|
+
yieldToHost?: () => Promise<void>;
|
|
19
|
+
}): Promise<boolean> {
|
|
20
|
+
const yieldEveryChunks =
|
|
21
|
+
input.yieldEveryChunks !== undefined && Number.isFinite(input.yieldEveryChunks) && input.yieldEveryChunks > 0
|
|
22
|
+
? Math.floor(input.yieldEveryChunks)
|
|
23
|
+
: 1;
|
|
24
|
+
const yieldToHost = input.yieldToHost ?? yieldToHostEventLoop;
|
|
25
|
+
if (input.isCancelled()) return false;
|
|
26
|
+
input.send({ type: "meta", requestId: input.requestId, theme: input.theme, status: input.status });
|
|
27
|
+
for (let index = 0; index < input.chunks.length; index += 1) {
|
|
28
|
+
if (input.isCancelled()) return false;
|
|
29
|
+
input.send({ type: "chunk", requestId: input.requestId, data: input.chunks[index] });
|
|
30
|
+
if ((index + 1) % yieldEveryChunks === 0) await yieldToHost();
|
|
31
|
+
}
|
|
32
|
+
if (input.isCancelled()) return false;
|
|
33
|
+
input.send({ type: "end", requestId: input.requestId });
|
|
34
|
+
return true;
|
|
35
|
+
}
|