@donkeylabs/server 2.0.7 → 2.0.11

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.
@@ -28,6 +28,44 @@ import { createConnection, type Socket } from "node:net";
28
28
  // Types
29
29
  // ============================================
30
30
 
31
+ /**
32
+ * Process stats collected and emitted to the server.
33
+ */
34
+ export interface ProcessStats {
35
+ /** CPU usage since last measurement */
36
+ cpu: {
37
+ /** User CPU time in microseconds */
38
+ user: number;
39
+ /** System CPU time in microseconds */
40
+ system: number;
41
+ /** CPU usage percentage (0-100) since last measurement */
42
+ percent: number;
43
+ };
44
+ /** Memory usage */
45
+ memory: {
46
+ /** Resident set size in bytes */
47
+ rss: number;
48
+ /** V8 heap total in bytes */
49
+ heapTotal: number;
50
+ /** V8 heap used in bytes */
51
+ heapUsed: number;
52
+ /** External memory in bytes (C++ objects bound to JS) */
53
+ external: number;
54
+ };
55
+ /** Process uptime in seconds */
56
+ uptime: number;
57
+ }
58
+
59
+ /**
60
+ * Configuration for stats emission.
61
+ */
62
+ export interface StatsConfig {
63
+ /** Enable stats emission (default: false) */
64
+ enabled?: boolean;
65
+ /** Interval between stats emissions in ms (default: 5000) */
66
+ interval?: number;
67
+ }
68
+
31
69
  export interface ProcessClientConfig {
32
70
  /** Process ID (from DONKEYLABS_PROCESS_ID env var) */
33
71
  processId: string;
@@ -43,6 +81,8 @@ export interface ProcessClientConfig {
43
81
  reconnectInterval?: number;
44
82
  /** Max reconnection attempts (default: 30) */
45
83
  maxReconnectAttempts?: number;
84
+ /** Stats emission configuration */
85
+ stats?: StatsConfig;
46
86
  }
47
87
 
48
88
  export interface ProcessClient {
@@ -72,13 +112,19 @@ class ProcessClientImpl implements ProcessClient {
72
112
  private heartbeatInterval: number;
73
113
  private reconnectInterval: number;
74
114
  private maxReconnectAttempts: number;
115
+ private statsConfig: StatsConfig;
75
116
 
76
117
  private heartbeatTimer?: ReturnType<typeof setInterval>;
118
+ private statsTimer?: ReturnType<typeof setInterval>;
77
119
  private reconnectTimer?: ReturnType<typeof setTimeout>;
78
120
  private reconnectAttempts = 0;
79
121
  private isDisconnecting = false;
80
122
  private _connected = false;
81
123
 
124
+ // For CPU percentage calculation
125
+ private lastCpuUsage?: NodeJS.CpuUsage;
126
+ private lastCpuTime?: number;
127
+
82
128
  constructor(config: ProcessClientConfig) {
83
129
  this.processId = config.processId;
84
130
  this.metadata = config.metadata ?? {};
@@ -87,6 +133,7 @@ class ProcessClientImpl implements ProcessClient {
87
133
  this.heartbeatInterval = config.heartbeatInterval ?? 5000;
88
134
  this.reconnectInterval = config.reconnectInterval ?? 2000;
89
135
  this.maxReconnectAttempts = config.maxReconnectAttempts ?? 30;
136
+ this.statsConfig = config.stats ?? { enabled: false };
90
137
  }
91
138
 
92
139
  get connected(): boolean {
@@ -99,6 +146,7 @@ class ProcessClientImpl implements ProcessClient {
99
146
  this._connected = true;
100
147
  this.reconnectAttempts = 0;
101
148
  this.startHeartbeat();
149
+ this.startStats();
102
150
 
103
151
  // Send initial "connected" message
104
152
  this.sendMessage({ type: "connected" });
@@ -122,6 +170,7 @@ class ProcessClientImpl implements ProcessClient {
122
170
  const onClose = () => {
123
171
  this._connected = false;
124
172
  this.stopHeartbeat();
173
+ this.stopStats();
125
174
 
126
175
  if (!this.isDisconnecting && this.reconnectAttempts < this.maxReconnectAttempts) {
127
176
  console.log(`[ProcessClient] Connection closed, attempting reconnect...`);
@@ -219,6 +268,69 @@ class ProcessClientImpl implements ProcessClient {
219
268
  }
220
269
  }
221
270
 
271
+ private startStats(): void {
272
+ if (!this.statsConfig.enabled) return;
273
+
274
+ this.stopStats();
275
+
276
+ // Initialize CPU tracking
277
+ this.lastCpuUsage = process.cpuUsage();
278
+ this.lastCpuTime = Date.now();
279
+
280
+ const interval = this.statsConfig.interval ?? 5000;
281
+ this.statsTimer = setInterval(() => {
282
+ this.sendStats();
283
+ }, interval);
284
+
285
+ // Send initial stats
286
+ this.sendStats();
287
+ }
288
+
289
+ private stopStats(): void {
290
+ if (this.statsTimer) {
291
+ clearInterval(this.statsTimer);
292
+ this.statsTimer = undefined;
293
+ }
294
+ }
295
+
296
+ private collectStats(): ProcessStats {
297
+ const now = Date.now();
298
+ const memUsage = process.memoryUsage();
299
+ const cpuUsage = process.cpuUsage(this.lastCpuUsage);
300
+
301
+ // Calculate CPU percentage
302
+ const elapsedMs = now - (this.lastCpuTime ?? now);
303
+ const elapsedUs = elapsedMs * 1000; // Convert to microseconds
304
+ const totalCpuUs = cpuUsage.user + cpuUsage.system;
305
+ // CPU percent = (CPU time used / elapsed time) * 100
306
+ // For multi-core, this can exceed 100%
307
+ const cpuPercent = elapsedUs > 0 ? (totalCpuUs / elapsedUs) * 100 : 0;
308
+
309
+ // Update for next calculation
310
+ this.lastCpuUsage = process.cpuUsage();
311
+ this.lastCpuTime = now;
312
+
313
+ return {
314
+ cpu: {
315
+ user: cpuUsage.user,
316
+ system: cpuUsage.system,
317
+ percent: Math.round(cpuPercent * 100) / 100, // Round to 2 decimals
318
+ },
319
+ memory: {
320
+ rss: memUsage.rss,
321
+ heapTotal: memUsage.heapTotal,
322
+ heapUsed: memUsage.heapUsed,
323
+ external: memUsage.external,
324
+ },
325
+ uptime: process.uptime(),
326
+ };
327
+ }
328
+
329
+ private sendStats(): void {
330
+ const stats = this.collectStats();
331
+ this.sendMessage({ type: "stats", stats });
332
+ }
333
+
222
334
  private sendMessage(message: { type: string; [key: string]: any }): boolean {
223
335
  if (!this.socket || this.socket.destroyed || !this._connected) {
224
336
  return false;
@@ -250,6 +362,7 @@ class ProcessClientImpl implements ProcessClient {
250
362
  disconnect(): void {
251
363
  this.isDisconnecting = true;
252
364
  this.stopHeartbeat();
365
+ this.stopStats();
253
366
 
254
367
  if (this.reconnectTimer) {
255
368
  clearTimeout(this.reconnectTimer);
@@ -291,14 +404,22 @@ export function createProcessClient(config: ProcessClientConfig): ProcessClient
291
404
  *
292
405
  * @example
293
406
  * ```ts
407
+ * // Basic connection
294
408
  * const client = await ProcessClient.connect();
295
409
  * client.emit("progress", { percent: 50 });
410
+ *
411
+ * // With stats emission
412
+ * const client = await ProcessClient.connect({
413
+ * stats: { enabled: true, interval: 2000 }
414
+ * });
296
415
  * ```
297
416
  */
298
417
  export async function connect(options?: {
299
418
  heartbeatInterval?: number;
300
419
  reconnectInterval?: number;
301
420
  maxReconnectAttempts?: number;
421
+ /** Enable real-time CPU/memory stats emission */
422
+ stats?: StatsConfig;
302
423
  }): Promise<ProcessClient> {
303
424
  const processId = process.env.DONKEYLABS_PROCESS_ID;
304
425
  const socketPath = process.env.DONKEYLABS_SOCKET_PATH;
@@ -81,6 +81,34 @@ export interface ManagedProcess {
81
81
  error?: string;
82
82
  }
83
83
 
84
+ /**
85
+ * Process stats received from a managed process.
86
+ */
87
+ export interface ProcessStats {
88
+ /** CPU usage since last measurement */
89
+ cpu: {
90
+ /** User CPU time in microseconds */
91
+ user: number;
92
+ /** System CPU time in microseconds */
93
+ system: number;
94
+ /** CPU usage percentage (0-100) since last measurement */
95
+ percent: number;
96
+ };
97
+ /** Memory usage */
98
+ memory: {
99
+ /** Resident set size in bytes */
100
+ rss: number;
101
+ /** V8 heap total in bytes */
102
+ heapTotal: number;
103
+ /** V8 heap used in bytes */
104
+ heapUsed: number;
105
+ /** External memory in bytes (C++ objects bound to JS) */
106
+ external: number;
107
+ };
108
+ /** Process uptime in seconds */
109
+ uptime: number;
110
+ }
111
+
84
112
  export interface ProcessDefinition {
85
113
  name: string;
86
114
  config: Omit<ProcessConfig, "args"> & { args?: string[] };
@@ -107,6 +135,18 @@ export interface ProcessDefinition {
107
135
  onUnhealthy?: (process: ManagedProcess) => void | Promise<void>;
108
136
  /** Called when the process is restarted */
109
137
  onRestart?: (oldProcess: ManagedProcess, newProcess: ManagedProcess, attempt: number) => void | Promise<void>;
138
+ /**
139
+ * Called when stats are received from the process.
140
+ * Stats are emitted by the process client when stats.enabled is true.
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * onStats: (process, stats) => {
145
+ * console.log(`${process.name}: CPU ${stats.cpu.percent}%, Memory ${stats.memory.rss / 1e6}MB`);
146
+ * }
147
+ * ```
148
+ */
149
+ onStats?: (process: ManagedProcess, stats: ProcessStats) => void | Promise<void>;
110
150
  }
111
151
 
112
152
  export interface SpawnOptions {
@@ -526,6 +566,33 @@ export class ProcessesImpl implements Processes {
526
566
  return;
527
567
  }
528
568
 
569
+ // Handle stats messages
570
+ if (type === "stats" && message.stats) {
571
+ const stats = message.stats as ProcessStats;
572
+
573
+ // Emit to events service as "process.<name>.stats"
574
+ await this.emitEvent(`process.${proc.name}.stats`, {
575
+ processId,
576
+ name: proc.name,
577
+ stats,
578
+ });
579
+
580
+ // Generic stats event
581
+ await this.emitEvent("process.stats", {
582
+ processId,
583
+ name: proc.name,
584
+ stats,
585
+ });
586
+
587
+ // Call definition callback
588
+ const definition = this.definitions.get(proc.name);
589
+ if (definition?.onStats) {
590
+ await definition.onStats(proc, stats);
591
+ }
592
+
593
+ return;
594
+ }
595
+
529
596
  // Handle typed event messages from ProcessClient.emit()
530
597
  if (type === "event" && message.event) {
531
598
  const eventName = message.event as string;
@@ -0,0 +1,403 @@
1
+ // Local Filesystem Storage Adapter
2
+ // Stores files in a local directory with metadata in .meta.json sidecar files
3
+
4
+ import { mkdir, readFile, writeFile, unlink, readdir, stat, rm } from "node:fs/promises";
5
+ import { join, dirname, basename, relative } from "node:path";
6
+ import { existsSync, createReadStream } from "node:fs";
7
+ import { Readable } from "node:stream";
8
+ import type {
9
+ StorageAdapter,
10
+ StorageFile,
11
+ UploadOptions,
12
+ UploadResult,
13
+ DownloadResult,
14
+ ListOptions,
15
+ ListResult,
16
+ GetUrlOptions,
17
+ CopyOptions,
18
+ LocalProviderConfig,
19
+ StorageVisibility,
20
+ } from "./storage";
21
+
22
+ interface FileMetadata {
23
+ contentType?: string;
24
+ metadata?: Record<string, string>;
25
+ visibility?: StorageVisibility;
26
+ contentDisposition?: string;
27
+ cacheControl?: string;
28
+ size: number;
29
+ lastModified: string;
30
+ }
31
+
32
+ /** Local filesystem storage adapter */
33
+ export class LocalStorageAdapter implements StorageAdapter {
34
+ private directory: string;
35
+ private baseUrl: string;
36
+
37
+ constructor(config: LocalProviderConfig) {
38
+ this.directory = config.directory;
39
+ this.baseUrl = config.baseUrl || "/storage";
40
+
41
+ // Ensure directory exists
42
+ this.ensureDirectory(this.directory);
43
+ }
44
+
45
+ private async ensureDirectory(dir: string): Promise<void> {
46
+ try {
47
+ await mkdir(dir, { recursive: true });
48
+ } catch (err) {
49
+ // Ignore if already exists
50
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
51
+ throw err;
52
+ }
53
+ }
54
+ }
55
+
56
+ private getFilePath(key: string): string {
57
+ // Normalize key to prevent directory traversal attacks
58
+ const normalizedKey = key.replace(/\.\./g, "").replace(/^\/+/, "");
59
+ return join(this.directory, normalizedKey);
60
+ }
61
+
62
+ private getMetaPath(key: string): string {
63
+ const filePath = this.getFilePath(key);
64
+ const dir = dirname(filePath);
65
+ const name = basename(filePath);
66
+ return join(dir, `.${name}.meta.json`);
67
+ }
68
+
69
+ private async readMetadata(key: string): Promise<FileMetadata | null> {
70
+ const metaPath = this.getMetaPath(key);
71
+ try {
72
+ const content = await readFile(metaPath, "utf-8");
73
+ return JSON.parse(content);
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ private async writeMetadata(key: string, metadata: FileMetadata): Promise<void> {
80
+ const metaPath = this.getMetaPath(key);
81
+ await this.ensureDirectory(dirname(metaPath));
82
+ await writeFile(metaPath, JSON.stringify(metadata, null, 2));
83
+ }
84
+
85
+ private async deleteMetadata(key: string): Promise<void> {
86
+ const metaPath = this.getMetaPath(key);
87
+ try {
88
+ await unlink(metaPath);
89
+ } catch {
90
+ // Ignore if doesn't exist
91
+ }
92
+ }
93
+
94
+ async upload(options: UploadOptions): Promise<UploadResult> {
95
+ const filePath = this.getFilePath(options.key);
96
+ await this.ensureDirectory(dirname(filePath));
97
+
98
+ // Convert body to buffer
99
+ const buffer = await this.toBuffer(options.body);
100
+
101
+ // Write file
102
+ await writeFile(filePath, buffer);
103
+
104
+ // Write metadata
105
+ const metadata: FileMetadata = {
106
+ contentType: options.contentType,
107
+ metadata: options.metadata,
108
+ visibility: options.visibility,
109
+ contentDisposition: options.contentDisposition,
110
+ cacheControl: options.cacheControl,
111
+ size: buffer.byteLength,
112
+ lastModified: new Date().toISOString(),
113
+ };
114
+ await this.writeMetadata(options.key, metadata);
115
+
116
+ const url =
117
+ options.visibility === "public" ? `${this.baseUrl}/${options.key}` : undefined;
118
+
119
+ return {
120
+ key: options.key,
121
+ size: buffer.byteLength,
122
+ url,
123
+ };
124
+ }
125
+
126
+ async download(key: string): Promise<DownloadResult | null> {
127
+ const filePath = this.getFilePath(key);
128
+
129
+ if (!existsSync(filePath)) {
130
+ return null;
131
+ }
132
+
133
+ const meta = await this.readMetadata(key);
134
+ const fileStat = await stat(filePath);
135
+
136
+ // Create readable stream
137
+ const nodeStream = createReadStream(filePath);
138
+ const webStream = Readable.toWeb(nodeStream) as unknown as ReadableStream<Uint8Array>;
139
+
140
+ return {
141
+ body: webStream,
142
+ size: fileStat.size,
143
+ contentType: meta?.contentType,
144
+ lastModified: new Date(meta?.lastModified || fileStat.mtime),
145
+ metadata: meta?.metadata,
146
+ };
147
+ }
148
+
149
+ async delete(key: string): Promise<boolean> {
150
+ const filePath = this.getFilePath(key);
151
+
152
+ try {
153
+ await unlink(filePath);
154
+ await this.deleteMetadata(key);
155
+ return true;
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
161
+ async deleteMany(keys: string[]): Promise<{ deleted: string[]; errors: string[] }> {
162
+ const deleted: string[] = [];
163
+ const errors: string[] = [];
164
+
165
+ for (const key of keys) {
166
+ if (await this.delete(key)) {
167
+ deleted.push(key);
168
+ } else {
169
+ errors.push(key);
170
+ }
171
+ }
172
+
173
+ return { deleted, errors };
174
+ }
175
+
176
+ async list(options: ListOptions = {}): Promise<ListResult> {
177
+ const { prefix = "", limit = 1000, cursor, delimiter } = options;
178
+
179
+ const prefixPath = prefix ? join(this.directory, prefix) : this.directory;
180
+ const files: StorageFile[] = [];
181
+ const prefixes: string[] = [];
182
+ const prefixSet = new Set<string>();
183
+
184
+ try {
185
+ await this.walkDirectory(
186
+ prefixPath,
187
+ this.directory,
188
+ prefix,
189
+ delimiter,
190
+ files,
191
+ prefixSet,
192
+ limit,
193
+ cursor
194
+ );
195
+ } catch (err) {
196
+ // Directory doesn't exist, return empty
197
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
198
+ return { files: [], prefixes: [], cursor: null, hasMore: false };
199
+ }
200
+ throw err;
201
+ }
202
+
203
+ // Sort files by key for consistent pagination
204
+ files.sort((a, b) => a.key.localeCompare(b.key));
205
+
206
+ // Apply cursor
207
+ let startIndex = 0;
208
+ if (cursor) {
209
+ startIndex = files.findIndex((f) => f.key > cursor);
210
+ if (startIndex === -1) startIndex = files.length;
211
+ }
212
+
213
+ const resultFiles = files.slice(startIndex, startIndex + limit);
214
+ const hasMore = startIndex + limit < files.length;
215
+ const nextCursor = hasMore ? resultFiles[resultFiles.length - 1]?.key || null : null;
216
+
217
+ return {
218
+ files: resultFiles,
219
+ prefixes: Array.from(prefixSet).sort(),
220
+ cursor: nextCursor,
221
+ hasMore,
222
+ };
223
+ }
224
+
225
+ private async walkDirectory(
226
+ dirPath: string,
227
+ baseDir: string,
228
+ prefix: string,
229
+ delimiter: string | undefined,
230
+ files: StorageFile[],
231
+ prefixSet: Set<string>,
232
+ limit: number,
233
+ cursor: string | undefined
234
+ ): Promise<void> {
235
+ let entries;
236
+ try {
237
+ entries = await readdir(dirPath, { withFileTypes: true });
238
+ } catch {
239
+ return;
240
+ }
241
+
242
+ for (const entry of entries) {
243
+ // Skip metadata files
244
+ if (entry.name.endsWith(".meta.json")) continue;
245
+
246
+ const fullPath = join(dirPath, entry.name);
247
+ const key = relative(baseDir, fullPath);
248
+
249
+ if (entry.isDirectory()) {
250
+ if (delimiter) {
251
+ // Add as prefix
252
+ prefixSet.add(key + delimiter);
253
+ } else {
254
+ // Recurse into directory
255
+ await this.walkDirectory(
256
+ fullPath,
257
+ baseDir,
258
+ prefix,
259
+ delimiter,
260
+ files,
261
+ prefixSet,
262
+ limit,
263
+ cursor
264
+ );
265
+ }
266
+ } else {
267
+ // Check if key matches prefix
268
+ if (!key.startsWith(prefix.replace(/\/$/, ""))) continue;
269
+
270
+ // Check cursor
271
+ if (cursor && key <= cursor) continue;
272
+
273
+ // Get file stats and metadata
274
+ const fileStat = await stat(fullPath);
275
+ const meta = await this.readMetadata(key);
276
+
277
+ files.push({
278
+ key,
279
+ size: fileStat.size,
280
+ contentType: meta?.contentType,
281
+ lastModified: new Date(meta?.lastModified || fileStat.mtime),
282
+ metadata: meta?.metadata,
283
+ visibility: meta?.visibility,
284
+ });
285
+
286
+ // Early exit if we have enough
287
+ if (files.length >= limit * 2) return;
288
+ }
289
+ }
290
+ }
291
+
292
+ async head(key: string): Promise<StorageFile | null> {
293
+ const filePath = this.getFilePath(key);
294
+
295
+ try {
296
+ const fileStat = await stat(filePath);
297
+ const meta = await this.readMetadata(key);
298
+
299
+ return {
300
+ key,
301
+ size: fileStat.size,
302
+ contentType: meta?.contentType,
303
+ lastModified: new Date(meta?.lastModified || fileStat.mtime),
304
+ metadata: meta?.metadata,
305
+ visibility: meta?.visibility,
306
+ };
307
+ } catch {
308
+ return null;
309
+ }
310
+ }
311
+
312
+ async exists(key: string): Promise<boolean> {
313
+ const filePath = this.getFilePath(key);
314
+ return existsSync(filePath);
315
+ }
316
+
317
+ async getUrl(key: string, options: GetUrlOptions = {}): Promise<string> {
318
+ // For local storage, we can only provide a path-based URL
319
+ // The actual serving needs to be handled by the application
320
+ let url = `${this.baseUrl}/${key}`;
321
+
322
+ if (options.download) {
323
+ const filename =
324
+ typeof options.download === "string" ? options.download : key.split("/").pop();
325
+ url += `?download=${encodeURIComponent(filename || "file")}`;
326
+ }
327
+
328
+ return url;
329
+ }
330
+
331
+ async copy(options: CopyOptions): Promise<UploadResult> {
332
+ const sourcePath = this.getFilePath(options.source);
333
+ const destPath = this.getFilePath(options.destination);
334
+
335
+ if (!existsSync(sourcePath)) {
336
+ throw new Error(`Source file not found: ${options.source}`);
337
+ }
338
+
339
+ await this.ensureDirectory(dirname(destPath));
340
+
341
+ // Read source file
342
+ const content = await readFile(sourcePath);
343
+ const sourceMeta = await this.readMetadata(options.source);
344
+
345
+ // Write destination file
346
+ await writeFile(destPath, content);
347
+
348
+ // Write destination metadata
349
+ const destMeta: FileMetadata = {
350
+ ...sourceMeta,
351
+ metadata: options.metadata ?? sourceMeta?.metadata,
352
+ visibility: options.visibility ?? sourceMeta?.visibility,
353
+ size: content.byteLength,
354
+ lastModified: new Date().toISOString(),
355
+ };
356
+ await this.writeMetadata(options.destination, destMeta);
357
+
358
+ const url =
359
+ destMeta.visibility === "public"
360
+ ? `${this.baseUrl}/${options.destination}`
361
+ : undefined;
362
+
363
+ return {
364
+ key: options.destination,
365
+ size: content.byteLength,
366
+ url,
367
+ };
368
+ }
369
+
370
+ stop(): void {
371
+ // Nothing to clean up for local adapter
372
+ }
373
+
374
+ /** Helper to convert various body types to Buffer */
375
+ private async toBuffer(
376
+ body: Buffer | Uint8Array | string | Blob | ReadableStream<Uint8Array>
377
+ ): Promise<Buffer> {
378
+ if (Buffer.isBuffer(body)) {
379
+ return body;
380
+ }
381
+ if (body instanceof Uint8Array) {
382
+ return Buffer.from(body);
383
+ }
384
+ if (typeof body === "string") {
385
+ return Buffer.from(body, "utf-8");
386
+ }
387
+ if (body instanceof Blob) {
388
+ const arrayBuffer = await body.arrayBuffer();
389
+ return Buffer.from(arrayBuffer);
390
+ }
391
+ if (body instanceof ReadableStream) {
392
+ const reader = body.getReader();
393
+ const chunks: Uint8Array[] = [];
394
+ while (true) {
395
+ const { done, value } = await reader.read();
396
+ if (done) break;
397
+ chunks.push(value);
398
+ }
399
+ return Buffer.concat(chunks);
400
+ }
401
+ throw new Error("Unsupported body type");
402
+ }
403
+ }