@isomorph.ai/app-sdk 1.4.2 → 1.5.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 +63 -0
- package/dist/browser.d.ts +1309 -0
- package/dist/browser.js +135 -0
- package/dist/index.js +7 -0
- package/dist/runtime.d.ts +6 -1
- package/dist/runtime.js +47 -5
- package/package.json +17 -3
package/dist/browser.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { lookup } from 'node:dns/promises';
|
|
5
|
+
import { isIP } from 'node:net';
|
|
6
|
+
import { chromium } from 'playwright-core';
|
|
7
|
+
import { jsonSchema, tool } from 'ai';
|
|
8
|
+
let active = false;
|
|
9
|
+
export function publicIPv4(address) {
|
|
10
|
+
if (isIP(address) !== 4)
|
|
11
|
+
return false;
|
|
12
|
+
const [a, b, c] = address.split('.').map(Number);
|
|
13
|
+
return !(a === 0 || a === 10 || a === 127 || a >= 224 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && [0, 168].includes(b) || a === 100 && b >= 64 && b <= 127 || a === 198 && ([18, 19].includes(b) || b === 51 && c === 100) || a === 203 && b === 0 && c === 113);
|
|
14
|
+
}
|
|
15
|
+
/** App-reviewed read workflows only; control names and selectors never come from the model. */
|
|
16
|
+
export async function createBrowserTools(workflow) {
|
|
17
|
+
if (active)
|
|
18
|
+
throw new Error('A browser run is already active.');
|
|
19
|
+
if (!workflow.pages.length)
|
|
20
|
+
throw new Error('Declare a read-only page.');
|
|
21
|
+
const pages = workflow.pages.map(value => {
|
|
22
|
+
const url = new URL(value);
|
|
23
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.port && url.port !== '443')
|
|
24
|
+
throw new Error('Browser pages require HTTPS without credentials or custom ports.');
|
|
25
|
+
return url;
|
|
26
|
+
});
|
|
27
|
+
const allowed = new Set(pages.map(url => url.origin + url.pathname));
|
|
28
|
+
const hosts = [...new Set(pages.map(url => url.hostname))];
|
|
29
|
+
active = true;
|
|
30
|
+
let browser;
|
|
31
|
+
let home;
|
|
32
|
+
let timer;
|
|
33
|
+
let closed = false;
|
|
34
|
+
const close = async () => {
|
|
35
|
+
if (closed)
|
|
36
|
+
return;
|
|
37
|
+
closed = true;
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
workflow.signal?.removeEventListener('abort', abort);
|
|
40
|
+
try {
|
|
41
|
+
await browser?.close();
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
if (home)
|
|
45
|
+
await rm(home, { recursive: true, force: true });
|
|
46
|
+
active = false;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const abort = () => { void close(); };
|
|
50
|
+
try {
|
|
51
|
+
if (workflow.signal?.aborted)
|
|
52
|
+
throw new Error('Browser run cancelled.');
|
|
53
|
+
const rules = [];
|
|
54
|
+
for (const host of hosts) {
|
|
55
|
+
const addresses = await lookup(host, { all: true, family: 4 });
|
|
56
|
+
if (!addresses.length || addresses.some(entry => !publicIPv4(entry.address)))
|
|
57
|
+
throw new Error('Browser destination is not public.');
|
|
58
|
+
rules.push(`MAP ${host} ${addresses[0].address}`);
|
|
59
|
+
}
|
|
60
|
+
home = await mkdtemp(join(tmpdir(), 'isomorph-browser-'));
|
|
61
|
+
const executablePath = workflow.executablePath ?? process.env.HARBOUR_PUPPETEER_EXECUTABLE_PATH;
|
|
62
|
+
browser = await chromium.launch({ headless: true, chromiumSandbox: true, ...(executablePath ? { executablePath } : {}), args: [`--host-resolver-rules=${rules.join(',')},MAP * ~NOTFOUND`, '--no-proxy-server', '--disable-quic', '--force-webrtc-ip-handling-policy=disable_non_proxied_udp'], env: { PATH: process.env.PATH ?? '', HOME: home, TMPDIR: process.env.TMPDIR ?? '/tmp' } });
|
|
63
|
+
const context = await browser.newContext({ acceptDownloads: false, serviceWorkers: 'block' });
|
|
64
|
+
await context.route('**/*', async (route) => {
|
|
65
|
+
const request = route.request();
|
|
66
|
+
const url = new URL(request.url());
|
|
67
|
+
if (!['GET', 'HEAD'].includes(request.method()) || url.protocol !== 'https:' || url.port && url.port !== '443' || !hosts.includes(url.hostname) || request.isNavigationRequest() && !allowed.has(url.origin + url.pathname))
|
|
68
|
+
return route.abort('blockedbyclient');
|
|
69
|
+
return route.continue();
|
|
70
|
+
});
|
|
71
|
+
await context.routeWebSocket('**/*', socket => socket.close());
|
|
72
|
+
const page = await context.newPage();
|
|
73
|
+
context.on('page', popup => { if (popup !== page)
|
|
74
|
+
void popup.close(); });
|
|
75
|
+
page.setDefaultTimeout(10_000);
|
|
76
|
+
timer = setTimeout(abort, 5 * 60_000);
|
|
77
|
+
timer.unref();
|
|
78
|
+
workflow.signal?.addEventListener('abort', abort, { once: true });
|
|
79
|
+
if (workflow.signal?.aborted) {
|
|
80
|
+
await close();
|
|
81
|
+
throw new Error('Browser run cancelled.');
|
|
82
|
+
}
|
|
83
|
+
let actions = 0;
|
|
84
|
+
const run = async (operation) => {
|
|
85
|
+
if (closed || workflow.signal?.aborted)
|
|
86
|
+
throw new Error('Browser session is closed.');
|
|
87
|
+
if (++actions > 50) {
|
|
88
|
+
await close();
|
|
89
|
+
throw new Error('Browser action limit reached.');
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return await operation();
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
await close();
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
const empty = jsonSchema({ type: 'object', properties: {}, additionalProperties: false });
|
|
100
|
+
return { close, tools: {
|
|
101
|
+
browserNavigate: tool({ description: 'Open an approved read-only page.', inputSchema: jsonSchema({ type: 'object', properties: { url: { type: 'string' } }, required: ['url'], additionalProperties: false }), execute: ({ url }) => run(async () => {
|
|
102
|
+
const target = new URL(url);
|
|
103
|
+
if (target.username || target.password || !allowed.has(target.origin + target.pathname))
|
|
104
|
+
throw new Error('Page is outside this workflow.');
|
|
105
|
+
await page.goto(target.href, { waitUntil: 'domcontentloaded' });
|
|
106
|
+
return { url: page.url() };
|
|
107
|
+
}) }),
|
|
108
|
+
browserObserve: tool({ description: 'Read page text and permitted controls.', inputSchema: empty, execute: () => run(async () => {
|
|
109
|
+
const text = await page.locator('body').innerText();
|
|
110
|
+
return { url: page.url(), text: text.slice(0, 16_000), truncated: text.length > 16_000, controls: Object.keys(workflow.controls ?? {}) };
|
|
111
|
+
}) }),
|
|
112
|
+
browserControl: tool({ description: 'Use an app-reviewed read-only control.', inputSchema: jsonSchema({ type: 'object', properties: { name: { type: 'string' }, value: { type: 'string', maxLength: 2000 } }, required: ['name'], additionalProperties: false }), execute: ({ name, value }) => run(async () => {
|
|
113
|
+
const control = workflow.controls?.[name];
|
|
114
|
+
if (!control || control.page !== new URL(page.url()).origin + new URL(page.url()).pathname)
|
|
115
|
+
throw new Error('Control is not approved on this page.');
|
|
116
|
+
const element = page.locator(control.selector);
|
|
117
|
+
if (await element.count() !== 1)
|
|
118
|
+
throw new Error('Control is missing or ambiguous.');
|
|
119
|
+
if (control.action === 'fill')
|
|
120
|
+
await element.fill(value ?? '');
|
|
121
|
+
else
|
|
122
|
+
await element.click();
|
|
123
|
+
return { url: page.url() };
|
|
124
|
+
}) }),
|
|
125
|
+
browserScroll: tool({ description: 'Scroll down.', inputSchema: empty, execute: () => run(async () => { await page.mouse.wheel(0, 600); return { scrolled: true }; }) }),
|
|
126
|
+
browserScreenshot: tool({ description: 'Capture the viewport.', inputSchema: empty, execute: () => run(async () => { const bytes = await page.screenshot({ type: 'png' }); if (bytes.length > 2 * 1024 * 1024)
|
|
127
|
+
throw new Error('Screenshot exceeds 2 MiB.'); return { mediaType: 'image/png', base64: bytes.toString('base64') }; }) }),
|
|
128
|
+
browserClose: tool({ description: 'Close the browser.', inputSchema: empty, execute: async () => { await close(); return { closed: true }; } }),
|
|
129
|
+
} };
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
await close();
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -64,6 +64,13 @@ function mergeHeaders(...layers) {
|
|
|
64
64
|
function runnerIdentityHeaders(identity) {
|
|
65
65
|
if (typeof location !== 'undefined')
|
|
66
66
|
return {};
|
|
67
|
+
const resolver = globalThis[Symbol.for('isomorph.actionIdentity')];
|
|
68
|
+
const actionToken = resolver?.();
|
|
69
|
+
if (actionToken !== undefined) {
|
|
70
|
+
if (!actionToken)
|
|
71
|
+
throw new IsomorphError('AUTH_REQUIRED', 'The action has no invoking-user identity.');
|
|
72
|
+
return { 'x-harbour-identity-context': actionToken };
|
|
73
|
+
}
|
|
67
74
|
const environment = globalThis.process?.env;
|
|
68
75
|
const name = environment?.ISOMORPH_IDENTITY_CONTEXT_HEADER;
|
|
69
76
|
const token = identity === 'second-user' ? environment?.ISOMORPH_IDENTITY_CONTEXT_SECOND_USER : environment?.ISOMORPH_IDENTITY_CONTEXT;
|
package/dist/runtime.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare const ENQUEUE_ACTION = "isomorph-jobs-enqueue";
|
|
|
9
9
|
export declare const GET_RUN_ACTION = "isomorph-jobs-get";
|
|
10
10
|
export declare const CANCEL_RUN_ACTION = "isomorph-jobs-cancel";
|
|
11
11
|
export interface ActionContext {
|
|
12
|
+
signal: AbortSignal;
|
|
12
13
|
user: {
|
|
13
14
|
id: string;
|
|
14
15
|
email: string;
|
|
@@ -72,6 +73,8 @@ export interface RuntimeOptions {
|
|
|
72
73
|
gatewayUrl: string;
|
|
73
74
|
mode: RuntimeMode;
|
|
74
75
|
tokenFile?: string;
|
|
76
|
+
/** False when the app has no runs table (ISOMORPH_JOB_RUNS=none): nothing reads or works it. */
|
|
77
|
+
jobRuns?: boolean;
|
|
75
78
|
log?: (line: string) => void;
|
|
76
79
|
/** The pause between queue passes while work is found; the tests shorten it. */
|
|
77
80
|
queuePollMs?: number;
|
|
@@ -82,6 +85,7 @@ export declare class Runtime {
|
|
|
82
85
|
private readonly running;
|
|
83
86
|
private readonly timers;
|
|
84
87
|
private stopped;
|
|
88
|
+
private aiBinding;
|
|
85
89
|
private queueLive;
|
|
86
90
|
private queueWake;
|
|
87
91
|
private readonly runs;
|
|
@@ -89,7 +93,7 @@ export declare class Runtime {
|
|
|
89
93
|
readonly server: import("http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
90
94
|
constructor(options: RuntimeOptions);
|
|
91
95
|
start(): Promise<number>;
|
|
92
|
-
/** Whether this process works the queue at all: not the gate, an app with jobs, a gateway to reach. */
|
|
96
|
+
/** Whether this process works the queue at all: not the gate, a runs table, an app with jobs, a gateway to reach. */
|
|
93
97
|
private worksQueue;
|
|
94
98
|
/**
|
|
95
99
|
* Starts the queue loop unless it is already live. The first pass runs at
|
|
@@ -110,6 +114,7 @@ export declare class Runtime {
|
|
|
110
114
|
private awaitDataPath;
|
|
111
115
|
stop(): Promise<void>;
|
|
112
116
|
private every;
|
|
117
|
+
private prepareAI;
|
|
113
118
|
private refreshToken;
|
|
114
119
|
private scheduleTick;
|
|
115
120
|
/** One scheduled run; true when the handler returned, false when it threw or the last tick's run is still going. */
|
package/dist/runtime.js
CHANGED
|
@@ -44,13 +44,21 @@
|
|
|
44
44
|
// in-process scheduler. Set by Provision on the
|
|
45
45
|
// hosted kit-runtime; `isomorph dev` leaves it
|
|
46
46
|
// unset and keeps cron in-process
|
|
47
|
+
// ISOMORPH_JOB_RUNS=none the app has no runs table (it never calls
|
|
48
|
+
// isomorph.jobs): no queue loop, and --run-job
|
|
49
|
+
// runs without waiting on the data path. Set by
|
|
50
|
+
// Provision; the table exists otherwise
|
|
47
51
|
// ISOMORPH_APP_ROOT the app root (default: the working directory)
|
|
52
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
48
53
|
import { createServer } from 'node:http';
|
|
49
54
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
50
55
|
import { register } from 'node:module';
|
|
51
56
|
import { join, resolve } from 'node:path';
|
|
52
57
|
import { pathToFileURL } from 'node:url';
|
|
53
58
|
import { createClient, IsomorphError } from './index.js';
|
|
59
|
+
const actionIdentity = new AsyncLocalStorage();
|
|
60
|
+
const identityKey = Symbol.for('isomorph.actionIdentity');
|
|
61
|
+
globalThis[identityKey] = () => actionIdentity.getStore();
|
|
54
62
|
export const ACTIONS_DIR = 'actions';
|
|
55
63
|
export const JOBS_DIR = 'jobs';
|
|
56
64
|
export const RUNS_TABLE = 'isomorph_job_runs';
|
|
@@ -227,6 +235,7 @@ export class Runtime {
|
|
|
227
235
|
running = new Set();
|
|
228
236
|
timers = [];
|
|
229
237
|
stopped = false;
|
|
238
|
+
aiBinding;
|
|
230
239
|
// The queue loop is live while a pass runs or its next pass is timed; a
|
|
231
240
|
// wake that arrives mid-pass is remembered so the pass after it looks again.
|
|
232
241
|
queueLive = false;
|
|
@@ -256,9 +265,9 @@ export class Runtime {
|
|
|
256
265
|
this.wakeQueue();
|
|
257
266
|
return port;
|
|
258
267
|
}
|
|
259
|
-
/** Whether this process works the queue at all: not the gate, an app with jobs, a gateway to reach. */
|
|
268
|
+
/** Whether this process works the queue at all: not the gate, a runs table, an app with jobs, a gateway to reach. */
|
|
260
269
|
worksQueue() {
|
|
261
|
-
return this.options.mode !== 'gate' && this.inventory.jobs.length > 0 && Boolean(this.options.gatewayUrl);
|
|
270
|
+
return this.options.mode !== 'gate' && this.options.jobRuns !== false && this.inventory.jobs.length > 0 && Boolean(this.options.gatewayUrl);
|
|
262
271
|
}
|
|
263
272
|
/**
|
|
264
273
|
* Starts the queue loop unless it is already live. The first pass runs at
|
|
@@ -313,7 +322,7 @@ export class Runtime {
|
|
|
313
322
|
this.log(`this app has no ${JOBS_DIR}/${name}.ts`);
|
|
314
323
|
return false;
|
|
315
324
|
}
|
|
316
|
-
if (this.options.gatewayUrl && !(await this.awaitDataPath()))
|
|
325
|
+
if (this.options.gatewayUrl && this.options.jobRuns !== false && !(await this.awaitDataPath()))
|
|
317
326
|
return false;
|
|
318
327
|
return this.runScheduled(name, new Date(Math.floor(now.getTime() / 60_000) * 60_000));
|
|
319
328
|
}
|
|
@@ -356,6 +365,30 @@ export class Runtime {
|
|
|
356
365
|
};
|
|
357
366
|
this.timers.push(setTimeout(loop, ms));
|
|
358
367
|
}
|
|
368
|
+
async prepareAI() {
|
|
369
|
+
const versionId = process.env.ISOMORPH_AI_VERSION_ID;
|
|
370
|
+
if (!versionId)
|
|
371
|
+
return;
|
|
372
|
+
this.aiBinding ??= (async () => {
|
|
373
|
+
const token = process.env.ISOMORPH_WORKLOAD_TOKEN;
|
|
374
|
+
if (!token)
|
|
375
|
+
throw new Error('AI workload identity is unavailable.');
|
|
376
|
+
const response = await fetch(`${this.options.gatewayUrl}/_harbour/internal/ai/connection`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify({ versionId }), signal: AbortSignal.timeout(15_000) });
|
|
377
|
+
if (!response.ok)
|
|
378
|
+
throw new Error('This app version has no active AI connection yet.');
|
|
379
|
+
const result = await response.json();
|
|
380
|
+
const binding = result.data;
|
|
381
|
+
if (!binding?.key || !binding.gatewayBaseUrl || new URL(binding.gatewayBaseUrl).protocol !== 'https:')
|
|
382
|
+
throw new Error('Invalid AI runtime binding.');
|
|
383
|
+
process.env.OPENAI_BASE_URL = binding.gatewayBaseUrl;
|
|
384
|
+
process.env.OPENAI_API_KEY = binding.key;
|
|
385
|
+
if (binding.modelAliases?.generation)
|
|
386
|
+
process.env.ISOMORPH_AI_GENERATION_MODEL_ALIAS = binding.modelAliases.generation;
|
|
387
|
+
if (binding.modelAliases?.image)
|
|
388
|
+
process.env.ISOMORPH_AI_IMAGE_MODEL_ALIAS = binding.modelAliases.image;
|
|
389
|
+
})().catch(error => { this.aiBinding = undefined; throw error; });
|
|
390
|
+
await this.aiBinding;
|
|
391
|
+
}
|
|
359
392
|
async refreshToken() {
|
|
360
393
|
if (!this.options.tokenFile)
|
|
361
394
|
return;
|
|
@@ -459,7 +492,10 @@ export class Runtime {
|
|
|
459
492
|
const app = header(request, 'X-Harbour-Verified-App');
|
|
460
493
|
if (!tenant || !app)
|
|
461
494
|
return refuse(response, 401, 'AUTH_REQUIRED', 'the request did not come through the Isomorph gateway', requestId);
|
|
462
|
-
const
|
|
495
|
+
const controller = new AbortController();
|
|
496
|
+
const abort = () => controller.abort();
|
|
497
|
+
response.once('close', abort);
|
|
498
|
+
const context = { signal: controller.signal, user: { id: header(request, 'X-Harbour-Verified-User-ID'), email: header(request, 'X-Harbour-Verified-User-Email') }, tenant, app, requestId: requestId ?? '' };
|
|
463
499
|
const raw = await readBody(request);
|
|
464
500
|
if (raw === undefined)
|
|
465
501
|
return refuse(response, 413, 'VALIDATION_FAILED', 'the request body is larger than 1 MiB', requestId);
|
|
@@ -477,8 +513,9 @@ export class Runtime {
|
|
|
477
513
|
return answer(response, 200, { data: await this.reserved(name, input, context) }, requestId);
|
|
478
514
|
if (!this.inventory.actions.includes(name))
|
|
479
515
|
return refuse(response, 404, 'NOT_FOUND', `this app has no ${ACTIONS_DIR}/${name}.ts`, requestId);
|
|
516
|
+
await this.prepareAI();
|
|
480
517
|
const handler = await loadHandler(this.options.root, ACTIONS_DIR, name);
|
|
481
|
-
const result = await handler(input, context);
|
|
518
|
+
const result = await actionIdentity.run(header(request, 'X-Harbour-Identity-Context'), () => handler(input, context));
|
|
482
519
|
return answer(response, 200, { data: result === undefined ? null : result }, requestId);
|
|
483
520
|
}
|
|
484
521
|
catch (error) {
|
|
@@ -487,6 +524,10 @@ export class Runtime {
|
|
|
487
524
|
this.log(`${name}: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
488
525
|
return refuse(response, 500, 'INTERNAL', `the action ${name} failed; its log names the cause`, requestId);
|
|
489
526
|
}
|
|
527
|
+
finally {
|
|
528
|
+
response.removeListener('close', abort);
|
|
529
|
+
controller.abort();
|
|
530
|
+
}
|
|
490
531
|
}
|
|
491
532
|
async reserved(name, input, context) {
|
|
492
533
|
const body = (input && typeof input === 'object' ? input : {});
|
|
@@ -529,6 +570,7 @@ export function optionsFromEnvironment(env = process.env) {
|
|
|
529
570
|
gatewayUrl: (env.ISOMORPH_GATEWAY_URL || env.HARBOUR_GATEWAY_URL || '').replace(/\/$/, ''),
|
|
530
571
|
mode: env.ISOMORPH_RUNTIME_MODE === 'gate' ? 'gate' : env.ISOMORPH_JOBS_SCHEDULER === 'platform' ? 'actions' : 'full',
|
|
531
572
|
tokenFile: env.HARBOUR_WORKLOAD_TOKEN_FILE || undefined,
|
|
573
|
+
jobRuns: env.ISOMORPH_JOB_RUNS !== 'none',
|
|
532
574
|
};
|
|
533
575
|
}
|
|
534
576
|
/** The job named by `--run-job <name>` or `--run-job=<name>`; undefined when the runtime is a server. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isomorph.ai/app-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,7 +20,21 @@
|
|
|
20
20
|
"test": "npm run build && node --test test/*.test.mjs"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"
|
|
24
|
-
"
|
|
23
|
+
"@types/node": "24.7.0",
|
|
24
|
+
"ai": "7.0.118",
|
|
25
|
+
"playwright-core": "1.58.2",
|
|
26
|
+
"typescript": "5.9.2"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"ai": "^7.0.118",
|
|
30
|
+
"playwright-core": "^1.58.2"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"ai": {
|
|
34
|
+
"optional": true
|
|
35
|
+
},
|
|
36
|
+
"playwright-core": {
|
|
37
|
+
"optional": true
|
|
38
|
+
}
|
|
25
39
|
}
|
|
26
40
|
}
|