@seanhogg/builderforce-webcontainers 2026.10.6 → 2026.10.7
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/dist/assets.js +2 -2
- package/dist/check/worker.js +8 -8
- package/dist/host.d.ts +19 -9
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +15 -20
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/node/index.d.ts +1 -1
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/processHost.d.ts +13 -0
- package/dist/node/processHost.d.ts.map +1 -1
- package/dist/node/processHost.js +5 -0
- package/dist/node/processHost.js.map +1 -1
- package/dist/node/worker.js +72 -72
- package/dist/previewMessages.d.ts +19 -0
- package/dist/previewMessages.d.ts.map +1 -0
- package/dist/previewMessages.js +52 -0
- package/dist/previewMessages.js.map +1 -0
- package/dist/process-worker.js +72 -72
- package/dist/relay.html +1 -1
- package/package.json +2 -2
- package/src/host.ts +34 -26
- package/src/index.ts +3 -2
- package/src/node/index.ts +1 -1
- package/src/node/processHost.ts +19 -0
- package/src/previewMessages.ts +71 -0
package/dist/relay.html
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
<!doctype html><html><head><meta charset="utf-8"><title>Preview relay</title></head><body><script>"use strict";(()=>{var a="bfwc:attach",c="bfwc:reattach",n="bfwc:relay-ready",p="bfwc:relay-attach",
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><title>Preview relay</title></head><body><script>"use strict";(()=>{var a="bfwc:attach",c="bfwc:reattach",n="bfwc:relay-ready",p="bfwc:relay-attach",f="bfwc:relay-reattach",l="bfwc:relay-reload",d="bfwc:relay-spawn";function m(e,t){t.onmessage=r=>e.postMessage({kind:"message",data:r.data}),t.onerror=r=>e.postMessage({kind:"error",message:r.message||"The process worker failed."}),e.onmessage=r=>{let o=r.data;o?.kind==="message"?t.postMessage(o.data):o?.kind==="terminate"&&(t.terminate(),e.close())}}var u="process-worker.js";function i(e){return`bfwc:reload:${e}`}function g(e){if(e.active)return Promise.resolve(e.active);let t=e.installing??e.waiting;return t?new Promise((r,o)=>{t.addEventListener("statechange",()=>{t.state==="activated"&&e.active&&r(e.active),t.state==="redundant"&&o(new Error("The preview service worker failed to install."))})}):Promise.reject(new Error("The preview service worker did not install."))}function E(e){if(typeof BroadcastChannel>"u")return;let t=new BroadcastChannel(i(e));t.postMessage("reload"),t.close()}var s=window.parent;async function x(){let e=navigator.serviceWorker;if(!e)throw new Error("Service workers are unavailable on the preview origin.");let t=await e.register(new URL("./sw.js",location.href).href,{scope:"./"});await g(t),e.addEventListener("message",r=>{r.data?.type===c&&typeof r.data.id=="string"&&s.postMessage({type:f,id:r.data.id},"*")}),addEventListener("message",r=>{if(r.source!==s)return;let o=r.data;o?.type===p&&typeof o.id=="string"&&r.ports[0]?t.active?.postMessage({type:a,id:o.id},[r.ports[0]]):o?.type===l&&typeof o.base=="string"?E(o.base):o?.type===d&&r.ports[0]&&m(r.ports[0],new Worker(new URL(`./${u}`,location.href),{name:"bfwc-process"}))}),s.postMessage({type:n},"*")}x().catch(e=>{s.postMessage({type:n,error:e instanceof Error?e.message:String(e)},"*")});})();
|
|
2
2
|
</script></body></html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seanhogg/builderforce-webcontainers",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.7",
|
|
4
4
|
"description": "BuilderForce WebContainers — browser runtime. Boots an in-browser dev server for a project held in memory and serves it to an iframe through a service worker. Open source, no COOP/COEP, no session caps.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"esbuild-wasm": "^0.24.0",
|
|
68
|
-
"@seanhogg/builderforce-webcontainers-core": "^2026.10.
|
|
68
|
+
"@seanhogg/builderforce-webcontainers-core": "^2026.10.7"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@vue/compiler-sfc": "^3.5.0",
|
package/src/host.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DevServer,
|
|
3
|
-
ERROR_MESSAGE_TYPE,
|
|
4
3
|
VirtualFileSystem,
|
|
5
4
|
buildProject,
|
|
5
|
+
exportZip,
|
|
6
|
+
importZip,
|
|
6
7
|
type BuildResult,
|
|
7
8
|
type Bundler,
|
|
8
9
|
type ComponentCompilers,
|
|
@@ -14,9 +15,11 @@ import {
|
|
|
14
15
|
type RuntimeProcess,
|
|
15
16
|
type ServerAddress,
|
|
16
17
|
type Transformer,
|
|
18
|
+
type ZipExportOptions,
|
|
17
19
|
} from '@seanhogg/builderforce-webcontainers-core';
|
|
18
20
|
import { PREVIEW_ID, isPortPath, isWireRequest, previewBase, type WireRequest, type WireResponse } from './protocol.js';
|
|
19
|
-
import { createProcessHost, servePortRequest, type NodeRuntimeOptions } from './node/index.js';
|
|
21
|
+
import { createProcessHost, servePortRequest, type NodeRuntimeOptions, type ProcessInfo } from './node/index.js';
|
|
22
|
+
import { listenToPreview, type PreviewConsoleMessage, type PreviewError } from './previewMessages.js';
|
|
20
23
|
import { relayTransport, sameOriginTransport } from './transport.js';
|
|
21
24
|
import { createEsbuildWasmBundler, createEsbuildWasmTransformer } from './esbuildWasm.js';
|
|
22
25
|
import { createCdnComponentCompilers } from './componentCompilers.js';
|
|
@@ -59,6 +62,8 @@ export interface CommonRuntimeOptions {
|
|
|
59
62
|
attribution?: boolean;
|
|
60
63
|
/** Reload open previews when files change. Default true. */
|
|
61
64
|
liveReload?: boolean;
|
|
65
|
+
/** Your scripts (JavaScript source), run in every preview page before the app — analytics, test hooks, a mock `fetch`. */
|
|
66
|
+
previewScripts?: string[];
|
|
62
67
|
/** The Node runtime behind `run` (process workers, npm registry). */
|
|
63
68
|
node?: NodeRuntimeOptions;
|
|
64
69
|
}
|
|
@@ -66,12 +71,6 @@ export interface CommonRuntimeOptions {
|
|
|
66
71
|
/** Edits arrive in bursts (a save, an agent writing several files): reload once per burst. */
|
|
67
72
|
const RELOAD_DEBOUNCE_MS = 120;
|
|
68
73
|
|
|
69
|
-
export interface PreviewError {
|
|
70
|
-
message: string;
|
|
71
|
-
stack?: string;
|
|
72
|
-
href: string;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
74
|
export interface RuntimeBuildOptions {
|
|
76
75
|
/** Where the site will be served from. Default `./` (works from any directory). */
|
|
77
76
|
base?: string;
|
|
@@ -85,8 +84,14 @@ export interface PreviewRuntime {
|
|
|
85
84
|
readonly url: string;
|
|
86
85
|
readonly fs: VirtualFileSystem;
|
|
87
86
|
readonly server: DevServer;
|
|
88
|
-
/** Write files into the runtime (nested or flat; see `FileTree`). Open previews reload. */
|
|
89
|
-
writeTree(files: FileTree): void;
|
|
87
|
+
/** Write files into the runtime (nested or flat; see `FileTree`), under `at` (default `/`). Open previews reload. */
|
|
88
|
+
writeTree(files: FileTree, at?: string): void;
|
|
89
|
+
/** The project as a zip — "Download project" with `exclude: ['node_modules']`, or a full snapshot to restore later. */
|
|
90
|
+
exportZip(options?: ZipExportOptions): Uint8Array;
|
|
91
|
+
/** Write a zip's contents (an `exportZip` snapshot, a downloaded template) into the runtime, under `at`. */
|
|
92
|
+
importZip(zip: Uint8Array, at?: string): void;
|
|
93
|
+
/** Reload every frame showing this preview now (live reload does this on edits by itself). */
|
|
94
|
+
reload(): void;
|
|
90
95
|
/** Whether this runtime can serve the mounted project, and why not. */
|
|
91
96
|
profile(): ProjectProfile;
|
|
92
97
|
/**
|
|
@@ -97,6 +102,10 @@ export interface PreviewRuntime {
|
|
|
97
102
|
build(options?: RuntimeBuildOptions): Promise<BuildResult>;
|
|
98
103
|
/** Uncaught errors and rejections raised inside the preview. */
|
|
99
104
|
onError(listener: (error: PreviewError) => void): () => void;
|
|
105
|
+
/** The preview's `console.log/info/warn/error/debug` output (first 1,000 messages per page load). */
|
|
106
|
+
onPreviewConsole(listener: (message: PreviewConsoleMessage) => void): () => void;
|
|
107
|
+
/** Replace the scripts run in preview pages; open previews reload to pick them up. */
|
|
108
|
+
setPreviewScripts(scripts: string[]): void;
|
|
100
109
|
/**
|
|
101
110
|
* Run a command — `node server.js`, `npm install`, `npm run dev`, `bfsh` for a
|
|
102
111
|
* terminal — in its own Web Worker. Pass `{ terminal: { cols, rows } }` when
|
|
@@ -108,6 +117,8 @@ export interface PreviewRuntime {
|
|
|
108
117
|
onServerListening(listener: (server: ServerAddress) => void): () => void;
|
|
109
118
|
/** A virtual server opened or closed its port. */
|
|
110
119
|
onPortChange(listener: (change: PortChange) => void): () => void;
|
|
120
|
+
/** Processes still running, with the ports their servers listen on. */
|
|
121
|
+
listProcesses(): ProcessInfo[];
|
|
111
122
|
dispose(): void;
|
|
112
123
|
}
|
|
113
124
|
|
|
@@ -145,7 +156,7 @@ export async function startPreviewRuntime(options: PreviewRuntimeOptions): Promi
|
|
|
145
156
|
let server: DevServer;
|
|
146
157
|
try {
|
|
147
158
|
const transformer = options.transformer ?? (await createEsbuildWasmTransformer());
|
|
148
|
-
server = new DevServer({ fs, transformer, base, cdn: options.cdn, components, attribution: options.attribution });
|
|
159
|
+
server = new DevServer({ fs, transformer, base, cdn: options.cdn, components, attribution: options.attribution, previewScripts: options.previewScripts });
|
|
149
160
|
} catch (error) {
|
|
150
161
|
transport.dispose();
|
|
151
162
|
throw error;
|
|
@@ -182,38 +193,35 @@ export async function startPreviewRuntime(options: PreviewRuntimeOptions): Promi
|
|
|
182
193
|
reloadTimer = setTimeout(() => transport.reload(base), RELOAD_DEBOUNCE_MS);
|
|
183
194
|
});
|
|
184
195
|
|
|
185
|
-
const
|
|
186
|
-
const onWindowMessage = (event: MessageEvent) => {
|
|
187
|
-
const data = event.data as Partial<PreviewError> & { type?: string };
|
|
188
|
-
if (data?.type !== ERROR_MESSAGE_TYPE || typeof data.href !== 'string') return;
|
|
189
|
-
if (event.origin !== transport.origin || !new URL(data.href).pathname.startsWith(base)) return; // another runtime's preview
|
|
190
|
-
const error: PreviewError = { message: String(data.message ?? ''), stack: data.stack, href: data.href };
|
|
191
|
-
for (const listener of errorListeners) listener(error);
|
|
192
|
-
};
|
|
193
|
-
window.addEventListener('message', onWindowMessage);
|
|
196
|
+
const previewMessages = listenToPreview(window, transport.origin, base);
|
|
194
197
|
|
|
195
198
|
return {
|
|
196
199
|
id,
|
|
197
200
|
url: previewUrl,
|
|
198
201
|
fs,
|
|
199
202
|
server,
|
|
200
|
-
writeTree: (files) => fs.writeTree(files),
|
|
203
|
+
writeTree: (files, at) => fs.writeTree(files, at),
|
|
204
|
+
exportZip: (zipOptions) => exportZip(fs, zipOptions),
|
|
205
|
+
importZip: (zip, at) => importZip(fs, zip, at),
|
|
206
|
+
reload: () => transport.reload(base),
|
|
201
207
|
profile: () => server.profile(),
|
|
202
208
|
async build(buildOptions = {}) {
|
|
203
209
|
const bundler = options.bundler ?? (await createEsbuildWasmBundler());
|
|
204
210
|
return buildProject({ files: fs, bundler, cdn: options.cdn, components, base: buildOptions.base, minify: buildOptions.minify });
|
|
205
211
|
},
|
|
206
|
-
onError(listener)
|
|
207
|
-
|
|
208
|
-
|
|
212
|
+
onError: (listener) => previewMessages.onError(listener),
|
|
213
|
+
onPreviewConsole: (listener) => previewMessages.onConsole(listener),
|
|
214
|
+
setPreviewScripts(scripts) {
|
|
215
|
+
server.setPreviewScripts(scripts);
|
|
216
|
+
transport.reload(base);
|
|
209
217
|
},
|
|
210
218
|
run: (command: string, args?: string[] | ProcessOptions, runOptions?: ProcessOptions) => processes.run(command, args as string[], runOptions),
|
|
211
219
|
onServerListening: (listener) => processes.servers.onListening(listener),
|
|
212
220
|
onPortChange: (listener) => processes.servers.onPortChange(listener),
|
|
221
|
+
listProcesses: () => processes.list(),
|
|
213
222
|
dispose() {
|
|
214
223
|
processes.dispose();
|
|
215
|
-
|
|
216
|
-
errorListeners.clear();
|
|
224
|
+
previewMessages.dispose();
|
|
217
225
|
clearTimeout(reloadTimer);
|
|
218
226
|
stopWatching?.();
|
|
219
227
|
stopReattach();
|
package/src/index.ts
CHANGED
|
@@ -5,9 +5,10 @@ export type {
|
|
|
5
5
|
RelayOptions,
|
|
6
6
|
CommonRuntimeOptions,
|
|
7
7
|
PreviewRuntime,
|
|
8
|
-
PreviewError,
|
|
9
8
|
RuntimeBuildOptions,
|
|
10
9
|
} from './host.js';
|
|
10
|
+
export { listenToPreview } from './previewMessages.js';
|
|
11
|
+
export type { PreviewError, PreviewConsoleMessage, ConsoleLevel, PreviewMessages } from './previewMessages.js';
|
|
11
12
|
export type { PreviewTransport } from './transport.js';
|
|
12
13
|
export { createEsbuildWasmTransformer, createEsbuildWasmBundler, loadEsbuildWasm } from './esbuildWasm.js';
|
|
13
14
|
export type { EsbuildWasmOptions } from './esbuildWasm.js';
|
|
@@ -15,7 +16,7 @@ export { createCdnComponentCompilers, defaultVueCompilerUrl, defaultSvelteCompil
|
|
|
15
16
|
export type { CdnComponentCompilerOptions } from './componentCompilers.js';
|
|
16
17
|
export { createCacheStoragePackageCache } from './packageCache.js';
|
|
17
18
|
export { ProcessHost, createWorkerProcess } from './node/index.js';
|
|
18
|
-
export type { NodeRuntimeOptions, ProcessHostOptions, WorkerLike } from './node/index.js';
|
|
19
|
+
export type { NodeRuntimeOptions, ProcessHostOptions, ProcessInfo, WorkerLike } from './node/index.js';
|
|
19
20
|
export { createSwRouter } from './swRouter.js';
|
|
20
21
|
export type { SwRouter, SwRouterOptions, PortLike, PreviewTarget, RoutedResponse, RequestDetails } from './swRouter.js';
|
|
21
22
|
export { previewBase, previewPrefix, PREVIEW_SEGMENT, PREVIEW_ID, PORT_SEGMENT, isPortPath } from './protocol.js';
|
package/src/node/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { WireRequest, WireResponse } from '../protocol.js';
|
|
|
8
8
|
import { ProcessHost, type WorkerLike } from './processHost.js';
|
|
9
9
|
|
|
10
10
|
export { ProcessHost } from './processHost.js';
|
|
11
|
-
export type { ProcessHostOptions, WorkerLike } from './processHost.js';
|
|
11
|
+
export type { ProcessHostOptions, ProcessInfo, WorkerLike } from './processHost.js';
|
|
12
12
|
export { createWorkerProcess } from './workerProcess.js';
|
|
13
13
|
|
|
14
14
|
export interface NodeRuntimeOptions {
|
package/src/node/processHost.ts
CHANGED
|
@@ -50,8 +50,21 @@ export interface ProcessHostOptions {
|
|
|
50
50
|
|
|
51
51
|
const SIGNAL_CODES: Record<string, number> = { SIGINT: 130, SIGTERM: 143, SIGKILL: 137, SIGHUP: 129 };
|
|
52
52
|
|
|
53
|
+
/** A running process, as `listProcesses()` reports it. */
|
|
54
|
+
export interface ProcessInfo {
|
|
55
|
+
pid: number;
|
|
56
|
+
command: string;
|
|
57
|
+
args: string[];
|
|
58
|
+
cwd: string;
|
|
59
|
+
/** Epoch ms. */
|
|
60
|
+
startedAt: number;
|
|
61
|
+
/** Ports its servers listen on. */
|
|
62
|
+
ports: number[];
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
interface Proc {
|
|
54
66
|
pid: number;
|
|
67
|
+
info: Omit<ProcessInfo, 'ports'>;
|
|
55
68
|
worker: WorkerLike;
|
|
56
69
|
stdout: Set<(chunk: string) => void>;
|
|
57
70
|
stderr: Set<(chunk: string) => void>;
|
|
@@ -102,6 +115,7 @@ export class ProcessHost {
|
|
|
102
115
|
const exit = new Promise<number>((resolve) => (resolveExit = resolve));
|
|
103
116
|
const proc: Proc = {
|
|
104
117
|
pid,
|
|
118
|
+
info: { pid, command, args: [...args], cwd: options.cwd ?? '/', startedAt: Date.now() },
|
|
105
119
|
worker,
|
|
106
120
|
stdout: new Set(),
|
|
107
121
|
stderr: new Set(),
|
|
@@ -154,6 +168,11 @@ export class ProcessHost {
|
|
|
154
168
|
};
|
|
155
169
|
}
|
|
156
170
|
|
|
171
|
+
/** Every process still running, oldest first — children started by a shell included. */
|
|
172
|
+
list(): ProcessInfo[] {
|
|
173
|
+
return [...this.procs].map((proc) => ({ ...proc.info, args: [...proc.info.args], ports: [...proc.registrations.keys()] }));
|
|
174
|
+
}
|
|
175
|
+
|
|
157
176
|
/** Dispatch an HTTP request to the server on `port` (the preview service worker's `/__port/<port>/` path). */
|
|
158
177
|
request(port: number, request: HttpRequestData): Promise<HttpResponseData | undefined> {
|
|
159
178
|
return this.ports.request(port, request);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a preview reports to the host page: uncaught errors and console output,
|
|
3
|
+
* posted by the bridge script every served page starts with. One window
|
|
4
|
+
* listener per runtime, accepting only messages from the preview origin whose
|
|
5
|
+
* page sits under this runtime's base, so two runtimes on one page never hear
|
|
6
|
+
* each other's previews.
|
|
7
|
+
*/
|
|
8
|
+
import { CONSOLE_MESSAGE_TYPE, ERROR_MESSAGE_TYPE } from '@seanhogg/builderforce-webcontainers-core';
|
|
9
|
+
|
|
10
|
+
export interface PreviewError {
|
|
11
|
+
message: string;
|
|
12
|
+
stack?: string;
|
|
13
|
+
href: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ConsoleLevel = 'log' | 'info' | 'warn' | 'error' | 'debug';
|
|
17
|
+
|
|
18
|
+
export interface PreviewConsoleMessage {
|
|
19
|
+
level: ConsoleLevel;
|
|
20
|
+
/** The arguments flattened to text, capped at 4,000 characters. */
|
|
21
|
+
text: string;
|
|
22
|
+
href: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PreviewMessages {
|
|
26
|
+
onError(listener: (error: PreviewError) => void): () => void;
|
|
27
|
+
onConsole(listener: (message: PreviewConsoleMessage) => void): () => void;
|
|
28
|
+
dispose(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const LEVELS = new Set<string>(['log', 'info', 'warn', 'error', 'debug']);
|
|
32
|
+
|
|
33
|
+
function subscribe<T>(set: Set<(value: T) => void>, listener: (value: T) => void): () => void {
|
|
34
|
+
set.add(listener);
|
|
35
|
+
return () => set.delete(listener);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function listenToPreview(target: Pick<Window, 'addEventListener' | 'removeEventListener'>, origin: string, base: string): PreviewMessages {
|
|
39
|
+
const errors = new Set<(error: PreviewError) => void>();
|
|
40
|
+
const consoles = new Set<(message: PreviewConsoleMessage) => void>();
|
|
41
|
+
|
|
42
|
+
const onMessage = (event: MessageEvent) => {
|
|
43
|
+
const data = event.data as { type?: unknown; href?: unknown; message?: unknown; stack?: unknown; level?: unknown; text?: unknown } | null;
|
|
44
|
+
if (!data || typeof data.href !== 'string' || event.origin !== origin) return;
|
|
45
|
+
let path: string;
|
|
46
|
+
try {
|
|
47
|
+
path = new URL(data.href).pathname;
|
|
48
|
+
} catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!path.startsWith(base)) return; // another runtime's preview
|
|
52
|
+
if (data.type === ERROR_MESSAGE_TYPE) {
|
|
53
|
+
const error: PreviewError = { message: String(data.message ?? ''), stack: typeof data.stack === 'string' ? data.stack : undefined, href: data.href };
|
|
54
|
+
for (const listener of errors) listener(error);
|
|
55
|
+
} else if (data.type === CONSOLE_MESSAGE_TYPE && typeof data.level === 'string' && LEVELS.has(data.level)) {
|
|
56
|
+
const message: PreviewConsoleMessage = { level: data.level as ConsoleLevel, text: String(data.text ?? ''), href: data.href };
|
|
57
|
+
for (const listener of consoles) listener(message);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
target.addEventListener('message', onMessage as EventListener);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
onError: (listener) => subscribe(errors, listener),
|
|
64
|
+
onConsole: (listener) => subscribe(consoles, listener),
|
|
65
|
+
dispose() {
|
|
66
|
+
target.removeEventListener('message', onMessage as EventListener);
|
|
67
|
+
errors.clear();
|
|
68
|
+
consoles.clear();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|