dcp-worker 4.3.7 → 4.4.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/lib/web-iface.js CHANGED
@@ -1,128 +1,532 @@
1
1
  /**
2
- * https://stackoverflow.com/questions/52843900/blessed-server-node-js-over-websocket-to-xterm-js-client-in-browser
2
+ * @file web-iface.js - Web Interface for dcp-worker
3
+ * @author Wes Garland, wes@distributive.network
4
+ * @date July 2025
5
+ *
6
+ * @description
7
+ * This file sets up a daemon listening on localhost:9080 by default, which functions as a DCP Target.
8
+ *
9
+ * The security model is:
10
+ * 1. if the worker is unregistered, only the register command is supported, and anybody on the same
11
+ * local network as the worker can invoke it.
12
+ * 2. if the worker is registered, only commands sent by the registered owner are supported. This
13
+ * include the register command. "Same owner" is determined by the following algorithm:
14
+ * - Target receives DCP connection from Initiator (eg portal dcp-client on same host)
15
+ * - Initiator makes Request: signed message X that has messageId containing dcpsid
16
+ * - worker ensures X.messageId starts with dcpsid
17
+ * - worker sends message M to althea owner-check operation
18
+ * - M includes { innerMessage: X, operationKey: entity.worker, related: workerId }
19
+ * - althea compares peerAddress{entity.worker, r=workerId} and X.owner{entity.worker, r=workerId}
20
+ * - Althea sends Response: ok (or close connection when compare does not match)
21
+ * - Target memoizes connection as "good"
22
+ * - Initiator makes Request: tell me all your secrets
23
+ * - Target sends Response: connection "good" ? response with secrets : close connection
24
+ *
25
+ * Note: abc{ok,r=xyz} means "the deproxied address of abc for operation key ok" with pxa related=xyz
3
26
  */
4
- const blessed = require('blessed');
5
- const contrib = require('blessed-contrib');
6
- const path = require('path');
7
- const fs = require('fs');
8
- const http = require('http');
27
+ 'use strict';
28
+ const path = require('path');
29
+ const fs = require('fs');
30
+ const process = require('process');
31
+ const querystring = require('querystring');
32
+ const debug = require('debug');
9
33
 
10
- const progDir = path.dirname(require.main.filename);
11
- const wwwDir = path.resolve(progDir, '../www');
12
- const nmDir = path.resolve(progDir, '../node_modules');
13
- const listenHost = listenUrl.hostname === 'localhost' ? '::' : listenUrl.hostname;
34
+ const bootDate = new Date();
35
+ var worker;
36
+ var totalEarnings = 0;
14
37
 
15
- const magic = {
16
- html: 'text/html',
17
- txt: 'text/plain',
18
- js: 'application/javascript',
19
- css: 'text/css',
20
- gif: 'image/gif',
21
- jpeg: 'image/jpeg',
22
- jpg: 'image/jpeg',
23
- png: 'image/png',
24
- svg: 'image/svg+xml',
25
- kvin: 'application/x-kvin',
26
- }
38
+ /** Configuration for the server, must be mutated before a$init */
39
+ exports.config = { hostname: 'localhost', port: 9080 };
40
+
41
+ /**
42
+ * Web server initialization.
43
+ * @param {DistributiveWorker} __worker
44
+ * @returns {Promise} that is resolved when the server is listening
45
+ */
46
+ exports.a$init = function init(__worker)
47
+ {
48
+ const protocol = require('dcp/protocol');
49
+ const identity = require('dcp/identity');
50
+ var p$target, reject;
51
+
52
+ try
53
+ {
54
+ worker = __worker;
55
+ console.info(` . Starting management service on ${exports.config.hostname}:${exports.config.port}`);
56
+ const target = new protocol.Target(
57
+ ['socketio'],
58
+ {
59
+ identity: identity.get(),
60
+ listen: new URL(`http://${exports.config.hostname}:${exports.config.port}/dcp/`),
61
+ },
62
+ listeningHandler,
63
+ { socketio: { module: require('socket.io') } },
64
+ );
65
+ target.constructor.setBufferCtor(Buffer);
66
+ target.on('error', (error) => {
67
+ console.error('Management service error:', error.message);
68
+ reject(error);
69
+ });
70
+ target.on('end', (conn) => debug('dcp-worker:dcp')('connection closed'));
71
+ target.on('connection', (conn) => debug('dcp-worker:dcp')('new connection'));
72
+ target.on('session', (conn) => debug('dcp-worker:dcp')('new session'));
73
+ target.on('session', (conn) => newSessionHandler(conn, worker));
74
+ target.on('error', (error) => console.log('Management service error:', error));
75
+
76
+ p$target = new Promise((resolve, __reject) => {
77
+ reject = __reject;
78
+ target.on('listening', () => resolve(target));
79
+ setupWebServer(target);
80
+ });
81
+ }
82
+ catch(error)
83
+ {
84
+ throw new Error(`Unable to start management service: ${error.message}`);
85
+ }
27
86
 
28
- const clients = [ ];
87
+ worker.on('payment', function trackTotalEarnings(ev) {
88
+ const payment = parseFloat(ev);
89
+ if (!isNaN(payment))
90
+ totalEarnings += payment;
91
+ });
29
92
 
30
- function makeSkeletonConfig()
93
+ return p$target;
94
+ }
95
+
96
+ function listeningHandler()
31
97
  {
32
- return `window.dcpConfig = {
33
- scheduler: {
34
- location: new URL('${dcpConfig.scheduler.location.href}'),
35
- },
36
- const listenUrl = dcpConfig.webConsole.listen;
37
- worker: (${JSON.stringify(dcpConfig.worker)})
38
- }`;
98
+ debug('dcp-worker:dcp')('web interface listening');
39
99
  }
40
100
 
41
101
  /**
42
- * HTTP server
102
+ * Handle new session on connection conn from application connected over dcp to management web server.
103
+ * Implements an event relay for events of worker.
43
104
  */
44
- var server = http.createServer(handleHttpRequest).unref();
45
- function handleHttpRequest(request, response)
105
+ function newSessionHandler(conn, _worker)
46
106
  {
47
- if (request.url === '/etc/dcp-config.js')
107
+ const KVIN = new (require('dcp/internal/kvin').KVIN)();
108
+ const capturedEvents = {};
109
+
110
+ conn.on('request', request => requestHandler(request, worker));
111
+ /**
112
+ * Handle a request; these operations are supported:
113
+ * - event-on: capture events for event in request.payload.data, returning eventHandlerId
114
+ * - event-off: stop capturing events corresponding to eventHandlerId
115
+ * - task-info: get the current task info object
116
+ * - run-info: get the current run info object
117
+ * Captured events are automatically uncaptured when the connection closes.
118
+ */
119
+ function requestHandler(request, _worker)
48
120
  {
49
- response.setHeader('Content-Type', magic.js);
50
- response.end(makeSkeletonConfig());
51
- return;
121
+ try
122
+ {
123
+ switch(request.payload.operation)
124
+ {
125
+ case 'event-on':
126
+ {
127
+ const eventName = request.payload.data;
128
+ const eventHandler = function workerEventCapture(...args) {
129
+ const data = {
130
+ type: 'event',
131
+ name: eventName,
132
+ workerId: worker.id,
133
+ id: workerEventCapture.id,
134
+ kvinArgs: KVIN.stringify(args),
135
+ };
136
+ request.connection.notify(data).catch(error => conn.close());
137
+ };
138
+ eventHandler.id = request.id.match('[A-Za-z0-9]*$')[0]; /* unique id - used for event-off */
139
+ capturedEvents[eventHandler.id] = { eventName, eventHandler };
140
+ worker.setMaxListeners(worker.getMaxListeners() + 1);
141
+ worker.on(eventName, eventHandler);
142
+ request.respond({ eventHandlerId: eventHandler.id });
143
+ break;
144
+ }
145
+ case 'event-off':
146
+ {
147
+ const eventHandlerId = request.payload.data.eventHandlerId;
148
+ const { eventName, eventHandler } = capturedEvents[eventHandlerId];
149
+ delete capturedEvents[eventHandlerId];
150
+ worker.setMaxListeners(worker.getMaxListeners() - 1);
151
+ if (!eventHandler)
152
+ console.error('missing event handler for captured event', eventName, eventHandlerId); /* impossible? */
153
+ else
154
+ worker.off(eventName, eventHandler);
155
+ break;
156
+ }
157
+ case 'task-info':
158
+ request.respond(worker.__stiCache || { jobs: [], tasks: [] });
159
+ break;
160
+ case 'run-info':
161
+ request.respond(runInfoObjectFactory());
162
+ break;
163
+ case 'worker-id':
164
+ request.response(worker.id);
165
+ break;
166
+ default:
167
+ throw new Error(`invalid operation '${request.payload.operation}'`);
168
+ }
169
+ }
170
+ catch(error)
171
+ {
172
+ request.respond(error);
173
+ }
52
174
  }
53
175
 
54
- if (request.url === '/etc/dcp-config.kvin')
176
+ /* Remove all our worker event handlers when client connection closes */
177
+ conn.on('end', () => {
178
+ for (let eventName of Object.keys(capturedEvents))
179
+ {
180
+ for (let i=0; i < capturedEvents[eventName].length; i++)
181
+ {
182
+ const { eventHandler } = capturedEvents[eventName][i];
183
+ worker.off(eventName, eventHandler);
184
+ }
185
+ }
186
+ });
187
+ }
188
+
189
+ const magic = {
190
+ html: 'text/html',
191
+ shtml: 'text/html',
192
+ txt: 'text/plain',
193
+ js: 'application/javascript',
194
+ mjs: 'application/javascript',
195
+ css: 'text/css',
196
+ gif: 'image/gif',
197
+ jpeg: 'image/jpeg',
198
+ jpg: 'image/jpeg',
199
+ png: 'image/png',
200
+ svg: 'image/svg+xml',
201
+ ico: 'image/vnd.microsoft.icon',
202
+ json: 'application/json',
203
+ kvin: 'application/x-kvin',
204
+ };
205
+
206
+ function setupWebServer(app)
207
+ {
208
+ const identity = require('dcp/identity');
209
+ const wallet = require('dcp/wallet');
210
+ const KVIN = new (require('dcp/internal/kvin').KVIN)();
211
+
212
+ KVIN.userCtors.dcpEth$$Address = wallet.Address;
213
+ KVIN.userCtors.dcpUrl$$DcpURL = require('dcp/dcp-url').DcpURL;
214
+
215
+ app.use((req, res) => {
216
+ const ipAddress = req.socket.address().address;
217
+ const originalUrl = req.originalUrl;
218
+
219
+ let endFired = false;
220
+ res.on('finish', () => {
221
+ req = req.httpRequest;
222
+ res = res.httpResponse;
223
+ endFired = true;
224
+ console.log(`${res.statusCode} ${req.method.toUpperCase()} ${req.headers.host} ${originalUrl} ` +
225
+ `${ipAddress} ${res._hasBody ? res._contentLength : '-'}`);
226
+ });
227
+ res.on('close', () => {
228
+ if (!endFired)
229
+ console.log(`${res.statusCode} ${req.method.toUpperCase()} ${req.headers.host} ${originalUrl} ` +
230
+ `${ipAddress} [abort]`);
231
+ });
232
+ });
233
+
234
+ app.get('/favicon.ico', (req, res) => {
235
+ res.redirect(302, '/dcp-client/assets/favicon.ico');
236
+ });
237
+ app.get(/^(\/|\/admin\/?)$/, (req, res) => {
238
+ res.redirect(302, '/admin/index.html');
239
+ });
240
+ app.get(/^\/admin\/.+/, (req, res) => {
241
+ serveStaticContent(req, res, 'www');
242
+ });
243
+ app.get(/^(\/|\/hud\/?)$/, (req, res) => {
244
+ res.redirect(302, '/hud/index.html');
245
+ });
246
+ app.get(/^\/hud\/.+\.shtml/, (req, res) => {
247
+ serveStaticContent(req, res, 'www', { ssi: true });
248
+ });
249
+ app.get(/^\/hud\/.+/, (req, res) => {
250
+ serveStaticContent(req, res, 'www');
251
+ });
252
+ app.get(/^\/dcp-client\/.+/, (req, res) => {
253
+ serveStaticContent(req, res, path.dirname(require.resolve('dcp-client')), {
254
+ strip: '/dcp-client/', absoluteDocroot: true });
255
+ });
256
+ app.get('/etc/dcp-config.js', async (req, res) => {
257
+ res.set('Content-Type', magic.js);
258
+ res.send('window.dcpConfig = ' + await makeSkeletonConfig());
259
+ });
260
+ app.get(/\/worker\/?$/, (req, res) => {
261
+ res.redirect(302, '/worker/index.html');
262
+ });
263
+ app.get('/worker/id', (req, res) => {
264
+ res.set('Content-Type', 'text/plain');
265
+ res.send(worker.id);
266
+ });
267
+ app.get('/worker/pid', (req, res) => {
268
+ res.set('Content-Type', 'text/plain');
269
+ res.send(String(process.pid));
270
+ });
271
+ app.get('/worker/owner', (req, res) => {
272
+ res.set('Content-Type', 'text/plain');
273
+ res.send(String(identity.get().address));
274
+ });
275
+ app.get('/worker/task-info', (req, res) => {
276
+ res.set('Content-Type', 'application/javascript');
277
+ res.send('window.taskInfo=' + JSON.stringify(worker.__stiCache || { jobs: [], tasks: [] }));
278
+ });
279
+ app.get('/worker/run-info', (req, res) => {
280
+ res.set('Content-Type', 'application/javascript');
281
+ res.send('window.runInfo=' + JSON.stringify(runInfoObjectFactory()));
282
+ });
283
+ app.post('/worker/register', (req, res) => handlePostMessage(req, res, workerRegisterPostHandler));
284
+ }
285
+
286
+ /**
287
+ * Run handler function, then prepare a JSON response to a POST message. All responses have a 'success'
288
+ * property, which is true if the handler ran without rejecting. If the handler rejects, then the
289
+ * success property is false and the rejection argument (eg Error object) is copied to the response.
290
+ *
291
+ * The resolved value of the handler is added to the result object, unless it is an instance of Error. In
292
+ * that case, it is carefully copied into the result as a property named 'error'; this error property has
293
+ * all of the enumerable properties of the exception, plus message, name, stack, and code (if defined).
294
+ *
295
+ * @param {PseudoExpressRequest} request
296
+ * @param {PseudoExpressResponse} response
297
+ * @param {function} handler - receives query object
298
+ */
299
+ async function handlePostMessage(request, response, handler)
300
+ {
301
+ const responseBody = { success: false };
302
+
303
+ response.set('access-control-allow-origin', '*');
304
+ response.set('access-control-allow-headers', '*');
305
+
306
+ /* dest, src */
307
+ function copyError(obj, error)
55
308
  {
56
- response.setHeader('Content-Type', magic.kvin);
57
- response.end('(' + JSON.stringify(dcpConfig) + ')');
58
- return;
309
+ if (!obj)
310
+ obj = {};
311
+ Object.assign(obj, error);
312
+ obj.message = error.message;
313
+ obj.name = error.name;
314
+ obj.stack = error.stack;
315
+ if (error.code)
316
+ obj.code = error.code;
59
317
  }
60
318
 
61
- var filename = (request.url.slice(1) || 'index.html');
62
- const contentType = magic[path.extname(filename).slice(1)] || 'text/plain';
63
- var errorStatus, contentDir;
319
+ try
320
+ {
321
+ if (request.headers['content-type'] !== 'application/x-www-form-urlencoded')
322
+ throw new Error(`invalid request content-type '${request.headers['content-type']}'`);
64
323
 
65
- if (!filename.startsWith('node_modules/'))
66
- contentDir = wwwDir;
67
- else
324
+ const query = querystring.parse(request.body.toString('utf-8'));
325
+ const retval = await handler(query, request, response);
326
+ responseBody.success = true;
327
+ if (retval instanceof Error) /* error reported by handler */
328
+ copyError((responseBody.error = {}), retval);
329
+ else
330
+ Object.assign(responseBody, retval);
331
+ }
332
+ catch(error)
68
333
  {
69
- contentDir = nmDir;
70
- filename = filename.slice(13);
334
+ responseBody.success = false;
335
+ copyError(responseBody, error);
71
336
  }
72
337
 
73
- filename = path.resolve(contentDir, filename);
74
- console.log({contentDir,filename,requestUrl:request.url});
75
- if (!filename.startsWith(contentDir) || filename[0] !== '/')
76
- errorStatus = 401;
77
- else if (!fs.existsSync(filename))
78
- errorStatus = 404;
338
+ response.json(responseBody);
339
+ }
79
340
 
80
- if (errorStatus)
341
+ /**
342
+ * When we receive the registration post message, we respond with a JSON string that is designed for
343
+ * submitPost. The string deserializes to an object which has properties
344
+ * - status: true if successful, false if this function threw (see handlePostMessage)
345
+ * - error: if not successful because an error was thrown, this is an Error-like object like it
346
+ * - failExpn: if not successful because of another reason, this message explains why
347
+ * - pid: if successful, this is the process id of the worker that accepted the request.
348
+ * - owner: if successful, this the string value of the wallet.Address of the registered owner
349
+ *
350
+ * Once successful, the client polls /worker/pid to know that it has rebooted.
351
+ */
352
+ async function workerRegisterPostHandler(query, req, res)
353
+ {
354
+ var failExpn;
355
+ const utils = require('./utils');
356
+ const reg = await utils.registerWorker(worker, query.rkey, msg => failExpn = msg);
357
+ reg.failExpn = failExpn;
358
+ reg.pid = process.pid;
359
+
360
+ /* Restart the worker as soon as the HTTP response with the pid has been sent to the management UI */
361
+ function restartSelf()
81
362
  {
82
- response.setHeader('Content-Type', 'text/plain');
83
- response.statusCode = errorStatus;
84
- response.end(`${errorStatus} accessing ${filename}`);
85
- return;
363
+ require('../lib/pidfile').signal(worker.config.pidfile, 'SIGQUIT', 30, 'stopping worker on pid %i');
86
364
  }
365
+ res.on('finish', restartSelf);
366
+ setTimeout(restartSelf, 30e3).unref();
87
367
 
88
- response.setHeader('Content-Type', contentType);
89
- response.end(fs.readFileSync(filename));
368
+ return reg; /* object that came back from the scheduler when we posted the registration request */
90
369
  }
91
370
 
92
- server.listen({ host: listenHost, port: listenUrl.port }, function(server) {
93
- console.log(`listening on port ${listenUrl.href}`);
94
- });
371
+ function filenameFromPathname(docroot, pathname, strip)
372
+ {
373
+ if (strip)
374
+ {
375
+ if (!pathname.startsWith(strip))
376
+ throw new Error(`invalid pathname, does not start with '${strip}'`);
377
+ pathname = pathname.slice(strip.length);
378
+ }
379
+ const filename = path.join(docroot, pathname);
380
+ if (!filename.startsWith(docroot) || filename[docroot.length] !== '/')
381
+ throw new Error(`invalid pathname ${pathname} ${filename} ${docroot}`);
382
+ return filename;
383
+ }
95
384
 
96
385
  /**
97
- * WebSocket server
386
+ * relative docroot means relative to dcp-worker package dir
387
+ * @param {object} options options object with properties:
388
+ * - strip: chars to strip from pathname before joining to docroot
389
+ * - ssi: enable server-side-includes
98
390
  */
99
- const WebSocketServer = require('websocket').server;
100
- const wsServer = new WebSocketServer({
101
- httpServer: server,
102
- autoAcceptConnections: false
103
- });
104
-
105
- wsServer.on('request', handleWssRequest);
106
- function handleWssRequest(request)
391
+ function serveStaticContent(req, res, docroot, options)
107
392
  {
108
- const connection = request.accept(null, request.origin);
109
- connection.socket.unref();
110
- clients.push(connection);
111
-
112
- const write = connection.send;
113
- const read = connection.socket.read;
114
-
115
- connection.send(JSON.stringify({ type: 'log', facility: 'debug', message: 'hello, world' }));
116
- connection.on('message', messageHandler);
117
- connection.on('close', closeHandler);
118
-
119
- function messageHandler(message)
393
+ const hash = require('dcp/utils').hash;
394
+ const pathname = req.url.match('^[^?#]*')[0];
395
+ // @TODO: security issue: we are allowing reaching above the dcp-worker folder for serving webcontent.
396
+ // Repro for why this is required: try creating a new folder, and installing dcp-worker. npm will install
397
+ // dcp-worker and dcp-client in the node_modules folder, NOT having dcp-worker/node_modules/dcp-client.
398
+ // Thus, to serve this dcp-client, we need to reach above the dcp-worker root.
399
+ if (!options?.absoluteDocroot)
400
+ docroot = path.resolve(path.join(path.dirname(require.main.filename), '..', docroot));
401
+
402
+ const filename = filenameFromPathname(docroot, pathname, options?.strip);
403
+ const contentType = magic[path.extname(filename).slice(1)] || 'application/octet-stream';
404
+
405
+ try
120
406
  {
121
- console.log('message:', message)
122
- console.log('this:', this);
123
- }
407
+ let fileDate;
408
+ const cacheDate = !options?.ssi && (req.headers['if-modified-since'] && new Date(req.headers['if-modified-since']));
409
+ const nowDate = new Date();
410
+
411
+ if (cacheDate)
412
+ {
413
+ fileDate = Math.max(bootDate, fs.statSync(filename).mtime);
414
+ if (!options?.ssi && cacheDate < nowDate && cacheDate > fileDate && cacheDate > bootDate)
415
+ {
416
+ /* fast path to 304 without reading file in non-SSI content */
417
+ res.status(304).send('Not Modified');
418
+ return;
419
+ }
420
+ }
421
+
422
+ /** @todo add content log hook to PseudoExpress */
423
+ let contents = fs.readFileSync(filename);
424
+ if (options?.ssi)
425
+ {
426
+ contents = contents.toString('utf-8');
427
+ const directives = contents.match(/<!--#include virtual="(.[^"]+)" -->/g);
428
+ for (let directive of directives)
429
+ {
430
+ try
431
+ {
432
+ let inclPathname = directive.match(/"[^"]+"/)[0].slice(1, -1);
433
+ if (inclPathname[0] === '/')
434
+ inclPathname = inclPathname.slice(1);
435
+ else
436
+ inclPathname = path.resolve(path.dirname(pathname), inclPathname);
437
+ const insert = fs.readFileSync(filenameFromPathname(docroot, inclPathname));
438
+ contents = contents.replace(directive, insert);
439
+ }
440
+ catch(error)
441
+ {
442
+ throw new Error(`error '${error.message}' in ssi directive '${directive.slice(2,-2)}'`);
443
+ }
124
444
 
125
- function closeHandler()
445
+ if (cacheDate)
446
+ fileDate = Math.max(bootDate, fileDate, fs.statSync(filename).mtime);
447
+ }
448
+ }
449
+
450
+ if (cacheDate && !options?.ssi && cacheDate < nowDate && cacheDate > fileDate && cacheDate > bootDate)
451
+ {
452
+ res.status(304).send('Not Modified');
453
+ return;
454
+ }
455
+
456
+ const etag = `"${hash.calculate(hash.eh0, contents)}"`;
457
+ if (req.headers['if-none-match'] && req.headers['if-none-match'].split('/, ?/').includes(etag))
458
+ res.status(304).send('Not Modified');
459
+ else
460
+ {
461
+ res.set('etag', etag);
462
+ res.set('Content-Type', contentType);
463
+ res.send(contents);
464
+ }
465
+ }
466
+ catch(error)
126
467
  {
468
+ res.set('Content-Type', 'text/plain');
469
+ switch(error.code)
470
+ {
471
+ case 'ENOENT':
472
+ res.status(404).send('Not found: ' + pathname);
473
+ break;
474
+ default:
475
+ res.status(500).send('Internal Server Error ' + (error.code || '') + '\n' + error.stack);
476
+ break;
477
+ }
478
+ }
479
+ res.end();
480
+ }
481
+
482
+ function runInfoObjectFactory()
483
+ {
484
+ return {
485
+ workerId: worker.id,
486
+ startTime: Date.now() - performance.now(),
487
+ totalEarnings,
488
+ earningsAccount: worker.config.paymentAddress,
489
+ maxSandboxes: worker.config.maxSandboxes,
490
+ minimumWage: worker.config.minimumWage,
491
+ cores: Object.assign({}, worker.config.cores,
492
+ { gpu: worker.runInfo.gpuCores, gpuType: worker.runInfo.gpuType }),
493
+ gpuType: worker.runInfo.gpuType || '',
127
494
  }
128
495
  }
496
+
497
+ async function makeSkeletonConfig()
498
+ {
499
+ const owner = await require('./utils').getOwner(worker);
500
+ const ownerStr = owner ? `'${String(owner)}'` : 'undefined';
501
+
502
+ return `window.dcpConfig = {
503
+ scheduler: {
504
+ location: new URL('${dcpConfig.scheduler.location.href}'),
505
+ },
506
+ bank: {
507
+ location: new URL('${dcpConfig.bank.location.href}'),
508
+ },
509
+ portal: {
510
+ location: new URL('${dcpConfig.portal.location.href}'),
511
+ },
512
+ cdn: {
513
+ location: new URL('${dcpConfig.cdn.location.href}'),
514
+ },
515
+ pxAuth: {
516
+ location: new URL('${dcpConfig.pxAuth.location.href}'),
517
+ },
518
+ oAuth: {
519
+ identityIntegration: false,
520
+ walletIntegration: false,
521
+ },
522
+ worker: {
523
+ id: '${worker.id}',
524
+ _owner: ${ownerStr},
525
+ },
526
+ };
527
+
528
+ window.addEventListener('dcpclientready', () => {
529
+ dcpConfig.worker.owner = dcpConfig.worker._owner && new dcp.wallet.Address(dcpConfig.worker._owner);
530
+ });
531
+ `;
532
+ }
@@ -346,12 +346,19 @@ exports.init = function dashboardConsole$$Init()
346
346
 
347
347
  /**
348
348
  * throb API: calls with arguments set facility and message. First call without argument emits the
349
- * message. All calls without arguments advance the throbber.
349
+ * message. All calls without arguments advance the throbber. Null cancels the throbber.
350
350
  */
351
351
  console.throb = function dashboardConsole$$throb(...args)
352
352
  {
353
353
  const throb = dashboardConsole$$throb;
354
354
  var throbPos = throb.pos || 0;
355
+ if (args[0] === null && args.length === 1)
356
+ {
357
+ if (typeof throb.pos === 'number')
358
+ logPane.advanceThrob(' ');
359
+ throb.pos = undefined;
360
+ return;
361
+ }
355
362
  if (args.length)
356
363
  logPane.log(...args);
357
364
  const throbChars = '/-\\|';
@@ -45,12 +45,19 @@ exports.init = function stdioConsole$$init(options)
45
45
  const throbChars = '/-\\|';
46
46
  /**
47
47
  * throb API: Writes message to stdout with a newline. Throb char is appended to message. If no message,
48
- * advance the previous throb char.
48
+ * advance the previous throb char. Null cancels the throbber.
49
49
  */
50
50
  console.throb = function throb(...args) {
51
- if (args.length && lastWasThrobber)
51
+ if ((args.length && lastWasThrobber) || args[0] === null)
52
52
  stdout.write(' \n');
53
53
 
54
+ if (args[0] === null && args.length === 1)
55
+ {
56
+ lastWasThrobber = false;
57
+ throbIdx = 0;
58
+ return;
59
+ }
60
+
54
61
  for (let i=0; i < args.length; i++)
55
62
  {
56
63
  if (i)