@webqit/webflo 0.11.61 → 1.0.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.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/src/{Context.js → AbstractContext.js} +1 -9
  3. package/src/deployment-pi/origins/index.js +1 -1
  4. package/src/index.js +1 -9
  5. package/src/runtime-pi/HttpEvent.js +101 -81
  6. package/src/runtime-pi/HttpUser.js +126 -0
  7. package/src/runtime-pi/MessagingOverBroadcast.js +9 -0
  8. package/src/runtime-pi/MessagingOverChannel.js +85 -0
  9. package/src/runtime-pi/MessagingOverSocket.js +106 -0
  10. package/src/runtime-pi/MultiportMessagingAPI.js +81 -0
  11. package/src/runtime-pi/WebfloCookieStorage.js +27 -0
  12. package/src/runtime-pi/WebfloEventTarget.js +39 -0
  13. package/src/runtime-pi/WebfloMessageEvent.js +58 -0
  14. package/src/runtime-pi/WebfloMessagingAPI.js +69 -0
  15. package/src/runtime-pi/{Router.js → WebfloRouter.js} +3 -34
  16. package/src/runtime-pi/WebfloRuntime.js +52 -0
  17. package/src/runtime-pi/WebfloStorage.js +109 -0
  18. package/src/runtime-pi/client/ClientMessaging.js +5 -0
  19. package/src/runtime-pi/client/Context.js +2 -6
  20. package/src/runtime-pi/client/CookieStorage.js +17 -0
  21. package/src/runtime-pi/client/Router.js +3 -13
  22. package/src/runtime-pi/client/SessionStorage.js +33 -0
  23. package/src/runtime-pi/client/Url.js +24 -72
  24. package/src/runtime-pi/client/WebfloClient.js +544 -0
  25. package/src/runtime-pi/client/WebfloRootClient1.js +179 -0
  26. package/src/runtime-pi/client/WebfloRootClient2.js +109 -0
  27. package/src/runtime-pi/client/WebfloSubClient.js +165 -0
  28. package/src/runtime-pi/client/Workport.js +89 -161
  29. package/src/runtime-pi/client/generate.js +1 -1
  30. package/src/runtime-pi/client/index.js +13 -18
  31. package/src/runtime-pi/client/worker/ClientMessaging.js +5 -0
  32. package/src/runtime-pi/client/worker/Context.js +2 -6
  33. package/src/runtime-pi/client/worker/CookieStorage.js +17 -0
  34. package/src/runtime-pi/client/worker/SessionStorage.js +13 -0
  35. package/src/runtime-pi/client/worker/WebfloWorker.js +294 -0
  36. package/src/runtime-pi/client/worker/Workport.js +13 -73
  37. package/src/runtime-pi/client/worker/index.js +7 -18
  38. package/src/runtime-pi/index.js +1 -8
  39. package/src/runtime-pi/server/ClientMessaging.js +18 -0
  40. package/src/runtime-pi/server/ClientMessagingRegistry.js +57 -0
  41. package/src/runtime-pi/server/Context.js +2 -6
  42. package/src/runtime-pi/server/CookieStorage.js +17 -0
  43. package/src/runtime-pi/server/Router.js +2 -68
  44. package/src/runtime-pi/server/SessionStorage.js +53 -0
  45. package/src/runtime-pi/server/WebfloServer.js +755 -0
  46. package/src/runtime-pi/server/index.js +7 -18
  47. package/src/runtime-pi/util-http.js +268 -32
  48. package/src/runtime-pi/xURL.js +25 -22
  49. package/src/runtime-pi/xfetch.js +2 -2
  50. package/src/runtime-pi/Application.js +0 -29
  51. package/src/runtime-pi/Cookies.js +0 -82
  52. package/src/runtime-pi/Runtime.js +0 -21
  53. package/src/runtime-pi/client/Application.js +0 -76
  54. package/src/runtime-pi/client/Runtime.js +0 -525
  55. package/src/runtime-pi/client/createStorage.js +0 -58
  56. package/src/runtime-pi/client/worker/Application.js +0 -44
  57. package/src/runtime-pi/client/worker/Runtime.js +0 -275
  58. package/src/runtime-pi/server/Application.js +0 -101
  59. package/src/runtime-pi/server/Runtime.js +0 -558
  60. package/src/runtime-pi/xFormData.js +0 -24
  61. package/src/runtime-pi/xHeaders.js +0 -146
  62. package/src/runtime-pi/xRequest.js +0 -46
  63. package/src/runtime-pi/xRequestHeaders.js +0 -109
  64. package/src/runtime-pi/xResponse.js +0 -33
  65. package/src/runtime-pi/xResponseHeaders.js +0 -117
  66. package/src/runtime-pi/xxHttpMessage.js +0 -102
@@ -0,0 +1,755 @@
1
+ import Fs from 'fs';
2
+ import Url from 'url';
3
+ import Path from 'path';
4
+ import Http from 'http';
5
+ import Https from 'https';
6
+ import WebSocket from 'ws';
7
+ import Mime from 'mime-types';
8
+ import QueryString from 'querystring';
9
+ import { _from as _arrFrom, _any } from '@webqit/util/arr/index.js';
10
+ import { _isEmpty, _isObject } from '@webqit/util/js/index.js';
11
+ import { _each } from '@webqit/util/obj/index.js';
12
+ import { slice as _streamSlice } from 'stream-slice';
13
+ import { Readable as _ReadableStream } from 'stream';
14
+ import { WebfloRuntime } from '../WebfloRuntime.js';
15
+ import { Context } from './Context.js';
16
+ import { CookieStorage } from './CookieStorage.js';
17
+ import { SessionStorage } from './SessionStorage.js';
18
+ import { MessagingOverSocket } from '../MessagingOverSocket.js';
19
+ import { ClientMessagingRegistry } from './ClientMessagingRegistry.js';
20
+ import { HttpEvent } from '../HttpEvent.js';
21
+ import { HttpUser } from '../HttpUser.js';
22
+ import { Router } from './Router.js';
23
+ import { pattern } from '../util-url.js';
24
+ import xfetch from '../xfetch.js';
25
+ import '../util-http.js';
26
+
27
+ const parseDomains = (domains) => _arrFrom(domains).reduce((arr, str) => arr.concat(str.split(',')), []).map(str => str.trim()).filter(str => str);
28
+ const selectDomains = (serverDefs, matchingPort = null) => serverDefs.reduce((doms, def) => doms.length ? doms : (((!matchingPort || def.port === matchingPort) && parseDomains(def.domains || def.hostnames)) || []), []);
29
+
30
+ export class WebfloServer extends WebfloRuntime {
31
+
32
+ static get Context() { return Context; }
33
+
34
+ static get Router() { return Router; }
35
+
36
+ static get HttpEvent() { return HttpEvent; }
37
+
38
+ static get CookieStorage() { return CookieStorage; }
39
+
40
+ static get SessionStorage() { return SessionStorage; }
41
+
42
+ static get HttpUser() { return HttpUser; }
43
+
44
+ static create(cx) {
45
+ return new this(this.Context.create(cx));
46
+ }
47
+
48
+ #cx;
49
+ #servers = new Map;
50
+ #proxies = new Map;
51
+ #sockets = new Map;
52
+
53
+ // Typically for access by Router
54
+ get cx() { return this.#cx; }
55
+
56
+ constructor(cx) {
57
+ super();
58
+ if (!(cx instanceof this.constructor.Context)) {
59
+ throw new Error('Argument #2 must be a Webflo Context instance');
60
+ }
61
+ this.#cx = cx;
62
+ }
63
+
64
+ async initialize() {
65
+ const resolveContextObj = async (cx, force = false) => {
66
+ if (_isEmpty(cx.layout) || force) { cx.layout = await (new cx.config.deployment.Layout(cx)).read(); }
67
+ if (_isEmpty(cx.server) || force) { cx.server = await (new cx.config.runtime.Server(cx)).read(); }
68
+ if (_isEmpty(cx.env) || force) { cx.env = await (new cx.config.deployment.Env(cx)).read(); }
69
+ };
70
+ await resolveContextObj(this.#cx);
71
+ if (this.#cx.env.autoload !== false) {
72
+ Object.keys(this.#cx.env.entries).forEach(key => {
73
+ if (!(key in process.env)) {
74
+ process.env[key] = this.#cx.env.entries[key];
75
+ }
76
+ });
77
+ }
78
+ // ---------------
79
+ if (this.#cx.config.deployment.Proxy) {
80
+ const proxied = await (new this.#cx.config.deployment.Proxy(this.#cx)).read();
81
+ await Promise.all((proxied.entries || []).map(async vhost => {
82
+ let cx, hostnames = parseDomains(vhost.hostnames), port = vhost.port, proto = vhost.proto;
83
+ if (vhost.path) {
84
+ cx = this.#cx.constructor.create(this.#cx, Path.join(this.#cx.CWD, vhost.path));
85
+ await resolveContextObj(cx, true);
86
+ cx.dict.key = true;
87
+ // From the server that's most likely to be active
88
+ port || (port = cx.server.https.port || cx.server.port);
89
+ // The domain list that corresponds to the specified resolved port
90
+ hostnames.length || (hostnames = selectDomains([cx.server.https, cx.server], port));
91
+ // Or anyone available... hoping that the remote configs can eventually be in sync
92
+ hostnames.length || (hostnames = selectDomains([cx.server.https, cx.server]));
93
+ // The corresponding proto
94
+ proto || (proto = port === cx.server.https.port ? 'https' : 'http');
95
+ }
96
+ hostnames.length || (hostnames = ['*']);
97
+ this.#proxies.set(hostnames.sort().join('|'), { cx, hostnames, port, proto });
98
+ }));
99
+ }
100
+ // ---------------
101
+ this.control();
102
+ if (this.#cx.logger) {
103
+ if (this.#servers.size) {
104
+ this.#cx.logger.info(`> Server running! (${this.#cx.app.title || ''})`);
105
+ for (let [proto, def] of this.#servers) {
106
+ this.#cx.logger.info(`> ${proto.toUpperCase()} / ${def.domains.concat('').join(`:${def.port} / `)}`);
107
+ }
108
+ } else {
109
+ this.#cx.logger.info(`> Server not running! No port specified.`);
110
+ }
111
+ if (this.#proxies.size) {
112
+ this.#cx.logger.info(`> Reverse proxy active.`);
113
+ for (let [id, def] of this.#proxies) {
114
+ this.#cx.logger.info(`> ${id} >>> ${def.port}`);
115
+ }
116
+ }
117
+ this.#cx.logger.info(``);
118
+ }
119
+ }
120
+
121
+ control() {
122
+ // ---------------
123
+ if (!this.#cx.flags['test-only'] && !this.#cx.flags['https-only'] && this.#cx.server.port) {
124
+ const httpServer = Http.createServer((request, response) => this.handleNodeHttpRequest('http', request, response));
125
+ httpServer.listen(this.#cx.server.port);
126
+ // -------
127
+ let domains = parseDomains(this.#cx.server.domains);
128
+ if (!domains.length) { domains = ['*']; }
129
+ this.#servers.set('http', {
130
+ instance: httpServer,
131
+ port: this.#cx.server.port,
132
+ domains,
133
+ });
134
+ // Handle WebSocket connections
135
+ httpServer.on('upgrade', (request, socket, head) => {
136
+ this.handleNodeWsRequest(wss, 'ws', request, socket, head);
137
+ });
138
+ }
139
+ // ---------------
140
+ if (!this.#cx.flags['test-only'] && !this.#cx.flags['http-only'] && this.#cx.server.https.port) {
141
+ const httpsServer = Https.createServer((request, response) => this.handleNodeHttpRequest('https', request, response));
142
+ httpsServer.listen(this.#cx.server.https.port);
143
+ // -------
144
+ const addSSLContext = (serverConfig, domains) => {
145
+ if (!Fs.existsSync(serverConfig.https.keyfile)) return;
146
+ const cert = {
147
+ key: Fs.readFileSync(serverConfig.https.keyfile),
148
+ cert: Fs.readFileSync(serverConfig.https.certfile),
149
+ };
150
+ domains.forEach(domain => { httpsServer.addContext(domain, cert); });
151
+ }
152
+ // -------
153
+ let domains = parseDomains(this.#cx.server.https.domains);
154
+ if (!domains.length) { domains = ['*']; }
155
+ this.#servers.set('https', {
156
+ instance: httpsServer,
157
+ port: this.#cx.server.https.port,
158
+ domains,
159
+ });
160
+ // -------
161
+ addSSLContext(this.#cx.server, domains);
162
+ for (const [ /*id*/, vhost] of this.#proxies) {
163
+ vhost.cx && addSSLContext(vhost.cx.server, vhost.hostnames);
164
+ }
165
+ // Handle WebSocket connections
166
+ httpsServer.on('upgrade', (request, socket, head) => {
167
+ this.handleNodeWsRequest(wss, 'wss', request, socket, head);
168
+ });
169
+ }
170
+ // ---------------
171
+ const wss = new WebSocket.Server({ noServer: true });
172
+ }
173
+
174
+ #globalMessagingRegistry = new Map;
175
+ async handleNodeWsRequest(wss, proto, nodeRequest, socket, head) {
176
+ const [fullUrl, requestInit] = this.parseNodeRequest(proto, nodeRequest, false);
177
+ const scope = {};
178
+ scope.url = new URL(fullUrl);
179
+ // -----------------
180
+ // Level 1 validation
181
+ const hosts = [...this.#servers.values()].reduce((_hosts, server) => _hosts.concat(server.domains), []);
182
+ for (const [ /*id*/, vhost] of this.#proxies) {
183
+ if (vhost.hostnames.includes(scope.url.hostname) || (vhost.hostnames.includes('*') && !hosts.includes('*'))) {
184
+ scope.error = `Web sockets not supported over Webflo reverse proxies`;
185
+ break;
186
+ }
187
+ }
188
+ // -----------------
189
+ // Level 2 validation
190
+ if (!scope.error) {
191
+ if (!hosts.includes(scope.url.hostname) && !hosts.includes('*')) {
192
+ scope.error = 'Unrecognized host';
193
+ } else if (scope.url.protocol === 'ws:' && this.#cx.server.https.force) {
194
+ scope.error = `Only secure connections allowed (wss:)`;
195
+ } else if (scope.url.hostname.startsWith('www.') && this.#cx.server.force_www === 'remove') {
196
+ scope.error = `Connections not allowed over the www subdomain`;
197
+ } else if (!scope.url.hostname.startsWith('www.') && this.#cx.server.force_www === 'add') {
198
+ scope.error = `Connections only allowed over the www subdomain`;
199
+ }
200
+ }
201
+ // -----------------
202
+ // Level 3 validation
203
+ // and actual processing
204
+ scope.request = this.createRequest(scope.url.href, requestInit);
205
+ scope.session = this.constructor.SessionStorage.create(scope.request, { secret: this.#cx.env.entries.SESSION_KEY });
206
+ if (!scope.error) {
207
+ if (!(scope.clientMessagingRegistry = this.#globalMessagingRegistry.get(scope.session.sessionID))) {
208
+ scope.error = `Lost or invalid clientID`;
209
+ } else if (!(scope.clientMessaging = scope.clientMessagingRegistry.get(scope.url.pathname.split('/').pop()))) {
210
+ scope.error = `Lost or invalid portID`;
211
+ } else {
212
+ wss.handleUpgrade(nodeRequest, socket, head, (ws) => {
213
+ wss.emit('connection', ws, nodeRequest);
214
+ const wsw = new MessagingOverSocket(null, ws);
215
+ scope.clientMessaging.add(wsw);
216
+ });
217
+ }
218
+ }
219
+ // -----------------
220
+ // Errors?
221
+ if (scope.error) {
222
+ socket.write(
223
+ `HTTP/1.1 400 Bad Request\r\n` +
224
+ `Content-Type: text/plain\r\n` +
225
+ `Connection: close\r\n` +
226
+ `\r\n` +
227
+ `${scope.error}\r\n`
228
+ );
229
+ socket.destroy();
230
+ return;
231
+ }
232
+ }
233
+
234
+ async handleNodeHttpRequest(proto, nodeRequest, nodeResponse) {
235
+ const [fullUrl, requestInit] = this.parseNodeRequest(proto, nodeRequest);
236
+ const scope = {};
237
+ scope.url = new URL(fullUrl);
238
+ // -----------------
239
+ // Level 1 handling
240
+ const hosts = [...this.#servers.values()].reduce((_hosts, server) => _hosts.concat(server.domains), []);
241
+ for (const [ /*id*/, vhost] of this.#proxies) {
242
+ if (vhost.hostnames.includes(scope.url.hostname) || (vhost.hostnames.includes('*') && !hosts.includes('*'))) {
243
+ scope.response = await this.proxyFetch(vhost, scope.url, scope.init);
244
+ break;
245
+ }
246
+ }
247
+ // -----------------
248
+ // Level 2 handling
249
+ if (!scope.response) {
250
+ if (!hosts.includes(scope.url.hostname) && !hosts.includes('*')) {
251
+ scope.exit = { status: 500 };
252
+ scope.exitMessage = 'Unrecognized host';
253
+ } else if (scope.url.protocol === 'http:' && this.#cx.server.https.force) {
254
+ scope.exit = {
255
+ status: 302,
256
+ headers: { Location: (scope.url.protocol = 'https:', scope.url.href) }
257
+ };
258
+ } else if (scope.url.hostname.startsWith('www.') && this.#cx.server.force_www === 'remove') {
259
+ scope.exit = {
260
+ status: 302,
261
+ headers: { Location: (scope.url.hostname = scope.url.hostname.substr(4), scope.url.href) }
262
+ };
263
+ } else if (!scope.url.hostname.startsWith('www.') && this.#cx.server.force_www === 'add') {
264
+ scope.exit = {
265
+ status: 302,
266
+ headers: { Location: (scope.url.hostname = `www.${scope.url.hostname}`, scope.url.href) }
267
+ };
268
+ } else if (this.#cx.config.runtime.server.Redirects) {
269
+ scope.exit = ((await (new this.#cx.config.runtime.server.Redirects(this.#cx)).read()).entries || []).reduce((_rdr, entry) => {
270
+ return _rdr || ((_rdr = pattern(entry.from, scope.url.origin).exec(scope.url.href)) && {
271
+ status: entry.code || 302,
272
+ headers: { Location: _rdr.render(entry.to) }
273
+ });
274
+ }, null);
275
+ }
276
+ if (scope.exit) {
277
+ scope.response = new Response(scope.exitMessage, scope.exit);
278
+ }
279
+ }
280
+ // -----------------
281
+ // Level 3 handling
282
+ if (!scope.response) {
283
+ scope.response = await this.navigate(fullUrl, requestInit, {
284
+ request: nodeRequest,
285
+ response: nodeResponse
286
+ });
287
+ }
288
+ // -----------------
289
+ // For when response was sent during this.navigate()
290
+ if (!nodeResponse.headersSent) {
291
+ for (const [name, value] of scope.response.headers) {
292
+ const existing = nodeResponse.getHeader(name);
293
+ if (existing) nodeResponse.setHeader(name, [].concat(existing).concat(value));
294
+ else nodeResponse.setHeader(name, value);
295
+ }
296
+ // --------
297
+ nodeResponse.statusCode = scope.response.status;
298
+ nodeResponse.statusMessage = scope.response.statusText;
299
+ if (scope.response.headers.has('Location')) {
300
+ nodeResponse.end();
301
+ } else if (scope.response.body instanceof _ReadableStream) {
302
+ scope.response.body.pipe(nodeResponse);
303
+ } else if (scope.response.body instanceof ReadableStream) {
304
+ _ReadableStream.from(scope.response.body).pipe(nodeResponse);
305
+ } else {
306
+ let body = scope.response.body;
307
+ if (scope.response.headers.get('Content-Type') === 'application/json') {
308
+ body += '';
309
+ }
310
+ nodeResponse.end(body);
311
+ }
312
+ // -----------------
313
+ // Logging
314
+ if (this.#cx.logger) {
315
+ const log = this.generateLog({ url: fullUrl, method: nodeRequest.method }, scope.response);
316
+ this.#cx.logger.log(log);
317
+ }
318
+ }
319
+ }
320
+
321
+ parseNodeRequest(proto, nodeRequest, withBody = true) {
322
+ // Detected when using manual proxy setting in a browser
323
+ if (nodeRequest.url.startsWith(`${proto}://${nodeRequest.headers.host}`)) {
324
+ nodeRequest.url = nodeRequest.url.split(nodeRequest.headers.host)[1];
325
+ }
326
+ const fullUrl = proto + '://' + nodeRequest.headers.host + nodeRequest.url;
327
+ const requestInit = { method: nodeRequest.method, headers: nodeRequest.headers };
328
+ if (withBody && !['GET', 'HEAD'].includes(nodeRequest.method)) {
329
+ nodeRequest[Symbol.toStringTag] = 'ReadableStream';
330
+ requestInit.body = nodeRequest;
331
+ requestInit.duplex = 'half'; // See https://github.com/nodejs/node/issues/46221
332
+ }
333
+ return [fullUrl, requestInit];
334
+ }
335
+
336
+ writeAutoHeaders(headers, autoHeaders) {
337
+ autoHeaders.forEach(header => {
338
+ var headerName = header.name.toLowerCase(),
339
+ headerValue = header.value,
340
+ isAppend = headerName.startsWith('+') ? (headerName = headerName.substr(1), true) : false,
341
+ isPrepend = headerName.endsWith('+') ? (headerName = headerName.substr(0, headerName.length - 1), true) : false;
342
+ if (isAppend || isPrepend) {
343
+ headerValue = [headers.get(headerName) || '', headerValue].filter(v => v);
344
+ headerValue = isPrepend ? headerValue.reverse().join(',') : headerValue.join(',');
345
+ }
346
+ headers.set(headerName, headerValue);
347
+ });
348
+ }
349
+
350
+ writeRedirectHeaders(httpEvent, response) {
351
+ const xRedirectPolicy = httpEvent.request.headers.get('X-Redirect-Policy');
352
+ const xRedirectCode = httpEvent.request.headers.get('X-Redirect-Code') || 300;
353
+ const destinationUrl = new URL(response.headers.get('Location'), httpEvent.url.origin);
354
+ const isSameOriginRedirect = destinationUrl.origin === httpEvent.url.origin;
355
+ let isSameSpaRedirect, sparootsFile = Path.join(this.#cx.CWD, this.#cx.layout.PUBLIC_DIR, 'sparoots.json');
356
+ if (isSameOriginRedirect && xRedirectPolicy === 'manual-when-cross-spa' && Fs.existsSync(sparootsFile)) {
357
+ // Longest-first sorting
358
+ const sparoots = _arrFrom(JSON.parse(Fs.readFileSync(sparootsFile))).sort((a, b) => a.length > b.length ? -1 : 1);
359
+ const matchRoot = path => sparoots.reduce((prev, root) => prev || (`${path}/`.startsWith(`${root}/`) && root), null);
360
+ isSameSpaRedirect = matchRoot(destinationUrl.pathname) === matchRoot(httpEvent.url.pathname);
361
+ }
362
+ if (xRedirectPolicy === 'manual' || (!isSameOriginRedirect && xRedirectPolicy === 'manual-when-cross-origin') || (!isSameSpaRedirect && xRedirectPolicy === 'manual-when-cross-spa')) {
363
+ response.headers.set('X-Redirect-Code', response.status);
364
+ response.headers.set('Access-Control-Allow-Origin', '*');
365
+ response.headers.set('Cache-Control', 'no-store');
366
+ response.meta.status = xRedirectCode;
367
+ }
368
+ }
369
+
370
+ createRequest(href, init = {}, autoHeaders = []) {
371
+ const request = new Request(href, init);
372
+ this.writeAutoHeaders(request.headers, autoHeaders);
373
+ return request;
374
+ }
375
+
376
+ async proxyFetch(vhost, url, init) {
377
+ const scope = {};
378
+ scope.url = new URL(url);
379
+ scope.url.port = vhost.port;
380
+ if (vhost.proto) {
381
+ scope.url.protocol = vhost.proto;
382
+ }
383
+ // ---------
384
+ if (init instanceof Request) {
385
+ scope.init = init.clone();
386
+ scope.init.headers.set('Host', scope.url.host);
387
+ } else {
388
+ scope.init = { ...init, decompress: false/* honoured in xfetch() */ };
389
+ if (!scope.init.headers) scope.init.headers = {};
390
+ scope.init.headers.host = scope.url.host;
391
+ delete scope.init.headers.connection;
392
+ }
393
+ // ---------
394
+ try {
395
+ scope.response = await this.remoteFetch(scope.url, scope.init);
396
+ } catch (e) {
397
+ scope.response = new Response(`Reverse Proxy Error: ${e.message}`, { status: 500 });
398
+ console.error(e);
399
+ }
400
+ return scope.response;
401
+ }
402
+
403
+ async remoteFetch(request, ...args) {
404
+ let href = request;
405
+ if (request instanceof Request) {
406
+ href = request.url;
407
+ } else if (request instanceof URL) {
408
+ href = request.href;
409
+ }
410
+ const _response = xfetch(request, ...args);
411
+ // Save a reference to this
412
+ return _response.then(async response => {
413
+ // Stop loading status
414
+ return response;
415
+ });
416
+ }
417
+
418
+ async localFetch(httpEvent) {
419
+ const scope = {};
420
+ scope.filename = Path.join(this.#cx.CWD, this.#cx.layout.PUBLIC_DIR, decodeURIComponent(httpEvent.url.pathname));
421
+ scope.ext = Path.parse(httpEvent.url.pathname).ext;
422
+ // if is a directory search for index file matching the extention
423
+ if (!scope.ext && Fs.existsSync(scope.filename) && Fs.lstatSync(scope.filename).isDirectory()) {
424
+ scope.ext = '.html';
425
+ scope.index = `index${scope.ext}`;
426
+ scope.filename = Path.join(scope.filename, scope.index);
427
+ }
428
+ scope.acceptEncs = [];
429
+ scope.supportedEncs = { gzip: '.gz', br: '.br' };
430
+ // based on the URL path, extract the file extention. e.g. .js, .doc, ...
431
+ // and process encoding
432
+ if ((scope.acceptEncs = (httpEvent.request.headers.get('Accept-Encoding') || '').split(',').map((e) => e.trim())).length
433
+ && (scope.enc = scope.acceptEncs.reduce((prev, _enc) => prev || (Fs.existsSync(scope.filename + scope.supportedEncs[_enc]) && _enc), null))) {
434
+ scope.filename = scope.filename + scope.supportedEncs[scope.enc];
435
+ } else {
436
+ if (!Fs.existsSync(scope.filename)) return;
437
+ if (Object.values(scope.supportedEncs).includes(scope.ext)) {
438
+ scope.enc = Object.keys(scope.supportedEncs).reduce((prev, _enc) => prev || (scope.supportedEncs[_enc] === ext && _enc), null);
439
+ scope.ext = Path.parse(scope.filename.substring(0, scope.filename.length - scope.ext.length)).ext;
440
+ }
441
+ }
442
+ // read file from file system
443
+ return new Promise(resolve => {
444
+ Fs.readFile(scope.filename, function (err, data) {
445
+ if (err) {
446
+ scope.response = new Response(null, { status: 500, statusText: `Error reading static file: ${scope.filename}` });
447
+ } else {
448
+ // if the file is found, set Content-type and send data
449
+ const mime = Mime.lookup(scope.ext);
450
+ scope.response = new Response(data, {
451
+ headers: {
452
+ 'Content-Type': mime === 'application/javascript' ? 'text/javascript' : mime,
453
+ 'Content-Length': Buffer.byteLength(data)
454
+ }
455
+ });
456
+ if (scope.enc) {
457
+ scope.response.headers.set('Content-Encoding', scope.enc);
458
+ }
459
+ }
460
+ scope.response.meta.filename = scope.filename;
461
+ scope.response.meta.static = true;
462
+ scope.response.meta.index = scope.index;
463
+ resolve(scope.response);
464
+ });
465
+ });
466
+ }
467
+
468
+ async navigate(url, init = {}, detail = {}) {
469
+ const scope = { url, init, detail };
470
+ if (typeof scope.url === 'string') {
471
+ scope.url = new URL(scope.url, 'http://localhost');
472
+ }
473
+ scope.response = await new Promise(async (resolveResponse) => {
474
+ scope.handleRespondWith = async (response) => {
475
+ if (scope.finalResponseSeen) {
476
+ throw new Error('Final response already sent');
477
+ }
478
+ if (scope.initialResponseSeen) {
479
+ return await this.execPush(scope.clientMessaging, response);
480
+ }
481
+ resolveResponse(response);
482
+ };
483
+ // ---------------
484
+ // Request processing
485
+ scope.autoHeaders = this.#cx.config.runtime.server.Headers
486
+ ? ((await (new this.#cx.config.runtime.server.Headers(this.#cx)).read()).entries || []).filter(entry => pattern(entry.url, url.origin).exec(url.href))
487
+ : [];
488
+ scope.request = this.createRequest(scope.url.href, scope.init, scope.autoHeaders.filter((header) => header.type === 'request'));
489
+ scope.cookies = this.constructor.CookieStorage.create(scope.request);
490
+ scope.session = this.constructor.SessionStorage.create(scope.request, { secret: this.#cx.env.entries.SESSION_KEY });
491
+ const sessionID = scope.session.sessionID;
492
+ if (!this.#globalMessagingRegistry.has(sessionID)) {
493
+ this.#globalMessagingRegistry.set(sessionID, new ClientMessagingRegistry(this, sessionID));
494
+ }
495
+ scope.clientMessagingRegistry = this.#globalMessagingRegistry.get(sessionID);
496
+ scope.clientMessaging = scope.clientMessagingRegistry.createPort();
497
+ scope.user = this.constructor.HttpUser.create(
498
+ scope.request,
499
+ scope.session,
500
+ scope.clientMessaging
501
+ );
502
+ scope.httpEvent = this.constructor.HttpEvent.create(scope.handleRespondWith, {
503
+ request: scope.request,
504
+ detail: scope.detail,
505
+ cookies: scope.cookies,
506
+ session: scope.session,
507
+ user: scope.user,
508
+ client: scope.clientMessaging
509
+ });
510
+ // Restore session before dispatching
511
+ if (scope.request.method === 'GET'
512
+ && (scope.redirectMessageID = scope.httpEvent.url.query['redirect-message'])
513
+ && (scope.redirectMessage = scope.session.get(`redirect-message:${scope.redirectMessageID}`))) {
514
+ scope.session.delete(`redirect-message:${scope.redirectMessageID}`);
515
+ }
516
+ // Dispatch for response
517
+ scope.$response = await this.dispatch(scope.httpEvent, {}, async (event) => {
518
+ return await this.localFetch(event);
519
+ });
520
+ // Final reponse!!!
521
+ scope.finalResponseSeen = true;
522
+ if (scope.initialResponseSeen) {
523
+ // Send via background port
524
+ if (typeof scope.$response !== 'undefined') {
525
+ await this.execPush(scope.clientMessaging, scope.$response);
526
+ }
527
+ scope.clientMessaging.close();
528
+ return;
529
+ }
530
+ // Send normally
531
+ resolveResponse(scope.$response);
532
+ });
533
+ scope.initialResponseSeen = true;
534
+ scope.hasBackgroundActivity = !scope.finalResponseSeen || (scope.redirectMessage && !(scope.response instanceof Response && scope.response.headers.get('Location')));
535
+ scope.response = await this.normalizeResponse(scope.httpEvent, scope.response, scope.hasBackgroundActivity);
536
+ if (scope.hasBackgroundActivity) {
537
+ scope.response.headers.set('X-Background-Messaging', `ws:${scope.clientMessaging.portID}`);
538
+ }
539
+ // Reponse handlers
540
+ if (scope.response instanceof Response && scope.response.headers.get('Location')) {
541
+ this.writeRedirectHeaders(scope.httpEvent, scope.response);
542
+ if (scope.redirectMessage) {
543
+ scope.session.set(`redirect-message:${scope.redirectMessageID}`, scope.redirectMessage);
544
+ }
545
+ } else {
546
+ this.writeAutoHeaders(scope.response.headers, scope.autoHeaders.filter((header) => header.type === 'response'));
547
+ if (scope.httpEvent.request.method !== 'GET' && !scope.response.headers.get('Cache-Control')) {
548
+ scope.response.headers.set('Cache-Control', 'no-store');
549
+ }
550
+ scope.response.headers.set('Accept-Ranges', 'bytes');
551
+ scope.response = await this.satisfyRequestFormat(scope.httpEvent, scope.response);
552
+ if (scope.redirectMessage) {
553
+ this.execPush(scope.clientMessaging, scope.redirectMessage);
554
+ if (scope.finalResponseSeen) {
555
+ scope.clientMessaging.on('connected', () => {
556
+ setTimeout(() => {
557
+ scope.clientMessaging.close();
558
+ }, 100);
559
+ });
560
+ }
561
+ } else if (scope.finalResponseSeen) {
562
+ scope.clientMessaging.close();
563
+ }
564
+ }
565
+ return scope.response;
566
+ }
567
+
568
+ async satisfyRequestFormat(httpEvent, response) {
569
+ // Satisfy "Accept" header
570
+ const is404 = response.status === 404;
571
+ if (is404) return response;
572
+ const acceptedOrUnchanged = [202/*Accepted*/, 304/*Not Modified*/].includes(response.status);
573
+ const t = response.status === 404;
574
+ if (httpEvent.request.headers.get('Accept')) {
575
+ const requestAccept = httpEvent.request.headers.get('Accept', true);
576
+ if (requestAccept.match('text/html') && !response.meta.static) {
577
+ const data = acceptedOrUnchanged ? {} : await response.parse();
578
+ response = await this.render(httpEvent, data, response);
579
+ } else if (acceptedOrUnchanged) {
580
+ return response;
581
+ } else if (response.headers.get('Content-Type') && !requestAccept.match(response.headers.get('Content-Type'))) {
582
+ return new Response(response.body, { status: 406, headers: response.headers });
583
+ }
584
+ }
585
+ // Satisfy "Range" header
586
+ if (httpEvent.request.headers.get('Range') && !response.headers.get('Content-Range')
587
+ && (response.body instanceof ReadableStream || ArrayBuffer.isView(response.body))) {
588
+ if (t) {
589
+ console.log(httpEvent.request.url, response.body);
590
+ }
591
+ const rangeRequest = httpEvent.request.headers.get('Range', true);
592
+ const body = _ReadableStream.from(response.body);
593
+ // ...in partials
594
+ const totalLength = parseInt(response.headers.get('Content-Length') || 0);
595
+ const ranges = await rangeRequest.reduce(async (_ranges, range) => {
596
+ _ranges = await _ranges;
597
+ if (range[0] < 0 || (totalLength && range[0] > totalLength)
598
+ || (range[1] > -1 && (range[1] <= range[0] || (totalLength && range[1] >= totalLength)))) {
599
+ // The range is beyond upper/lower limits
600
+ _ranges.error = true;
601
+ }
602
+ if (!totalLength && range[0] === undefined) {
603
+ // totalLength is unknown and we cant read the trailing size specified in range[1]
604
+ _ranges.error = true;
605
+ }
606
+ if (_ranges.error) return _ranges;
607
+ if (totalLength) { range.clamp(totalLength); }
608
+ const partLength = range[1] - range[0] + 1;
609
+ _ranges.parts.push({
610
+ body: body.pipe(_streamSlice(range[0], range[1] + 1)),
611
+ range: range = `bytes ${range[0]}-${range[1]}/${totalLength || '*'}`,
612
+ length: partLength,
613
+ });
614
+ _ranges.totalLength += partLength;
615
+ return _ranges;
616
+ }, { parts: [], totalLength: 0 });
617
+ if (ranges.error) {
618
+ response.meta.status = 416;
619
+ response.headers.set('Content-Range', `bytes */${totalLength || '*'}`);
620
+ response.headers.set('Content-Length', 0);
621
+ } else {
622
+ // TODO: of ranges.parts is more than one, return multipart/byteranges
623
+ response = new Response(ranges.parts[0].body, {
624
+ status: 206,
625
+ statusText: response.statusText,
626
+ headers: response.headers,
627
+ });
628
+ response.headers.set('Content-Range', ranges.parts[0].range);
629
+ response.headers.set('Content-Length', ranges.totalLength);
630
+ }
631
+ }
632
+ return response;
633
+ }
634
+
635
+ #renderFileCache = new Map;
636
+ async render(httpEvent, data, response) {
637
+ const scope = {};
638
+ scope.router = new this.constructor.Router(this.#cx, httpEvent.url.pathname);
639
+ scope.rendering = await scope.router.route('render', httpEvent, data, async (httpEvent, data) => {
640
+ let renderFile, pathnameSplit = httpEvent.url.pathname.split('/');
641
+ while ((renderFile = Path.join(this.#cx.CWD, this.#cx.layout.PUBLIC_DIR, './' + pathnameSplit.join('/'), 'index.html'))
642
+ && (this.#renderFileCache.get(renderFile) === false/* false on previous runs */ || !Fs.existsSync(renderFile))) {
643
+ this.#renderFileCache.set(renderFile, false);
644
+ pathnameSplit.pop();
645
+ }
646
+ const dirPublic = Url.pathToFileURL(Path.resolve(Path.join(this.#cx.CWD, this.#cx.layout.PUBLIC_DIR)));
647
+ const instanceParams = QueryString.stringify({
648
+ file: renderFile,
649
+ url: dirPublic.href,// httpEvent.url.href,
650
+ root: this.#cx.CWD,
651
+ });
652
+ const { window, document } = await import('@webqit/oohtml-ssr/src/instance.js?' + instanceParams);
653
+ await new Promise(res => {
654
+ if (document.readyState === 'complete') return res();
655
+ document.addEventListener('load', res);
656
+ });
657
+ if (window.webqit?.oohtml?.configs) {
658
+ const {
659
+ BINDINGS_API: { api: bindingsConfig } = {},
660
+ HTML_IMPORTS: { attr: modulesContextAttrs } = {},
661
+ } = window.webqit.oohtml.configs;
662
+ if (bindingsConfig) {
663
+ document[bindingsConfig.bind]({
664
+ state: {},
665
+ ...(!_isObject(data) ? {} : data),
666
+ env: 'server',
667
+ navigator: null,
668
+ location: this.location,
669
+ network: null,
670
+ transition: null,
671
+ background: null
672
+ }, { diff: true });
673
+ let overridenKeys;
674
+ if (_isObject(data) && (overridenKeys = ['env', 'navigator', 'location', 'network', 'transition', 'background'].filter((k) => k in data)).length) {
675
+ console.error(`The following data properties were overridden: ${overridenKeys.join(', ')}`);
676
+ }
677
+ }
678
+ if (modulesContextAttrs) {
679
+ const newRoute = '/' + `routes/${httpEvent.url.pathname}`.split('/').map(a => (a => a.startsWith('$') ? '-' : a)(a.trim())).filter(a => a).join('/');
680
+ document.body.setAttribute(modulesContextAttrs.importscontext, newRoute);
681
+ }
682
+ }
683
+ // Append background-activity meta
684
+ let backgroundActivityMeta = document.querySelector('meta[name="X-Background-Messaging"]');
685
+ if (response.headers.has('X-Background-Messaging')) {
686
+ if (!backgroundActivityMeta) {
687
+ backgroundActivityMeta = document.createElement('meta');
688
+ backgroundActivityMeta.setAttribute('name', 'X-Background-Messaging');
689
+ document.head.prepend(backgroundActivityMeta);
690
+ }
691
+ backgroundActivityMeta.setAttribute('content', response.headers.get('X-Background-Messaging'));
692
+ } else if (backgroundActivityMeta) {
693
+ backgroundActivityMeta.remove();
694
+ }
695
+ // Append hydration data
696
+ const hydrationData = document.querySelector('script[rel="hydration"][type="application/json"]') || document.createElement('script');
697
+ hydrationData.setAttribute('type', 'application/json');
698
+ hydrationData.setAttribute('rel', 'hydration');
699
+ hydrationData.textContent = JSON.stringify(data);
700
+ document.body.append(hydrationData);
701
+ // Await rendering engine
702
+ if (window.webqit.$qCompilerImport) {
703
+ await new Promise(res => {
704
+ window.webqit.$qCompilerImport.then(res);
705
+ setTimeout(res, 300);
706
+ });
707
+ }
708
+ await new Promise(res => setTimeout(res, 50));
709
+ return window;
710
+ });
711
+ // Validate rendering
712
+ if (typeof scope.rendering !== 'string' && !(typeof scope.rendering?.toString === 'function')) {
713
+ throw new Error('render() must return a string response or an object that implements toString()..');
714
+ }
715
+ // Convert back to response
716
+ scope.response = new Response(scope.rendering, {
717
+ headers: response.headers,
718
+ status: response.status,
719
+ });
720
+ scope.response.headers.set('Content-Type', 'text/html');
721
+ scope.response.headers.set('Content-Length', (new Blob([scope.rendering])).size);
722
+ return scope.response;
723
+ }
724
+
725
+ generateLog(request, response, isproxy = false) {
726
+ let log = [];
727
+ // ---------------
728
+ const style = this.#cx.logger.style || { keyword: str => str, comment: str => str, url: str => str, val: str => str, err: str => str, };
729
+ const errorCode = [404, 500].includes(response.status) ? response.status : 0;
730
+ const xRedirectCode = response.headers.get('X-Redirect-Code');
731
+ const isRedirect = xRedirectCode || (response.status + '').startsWith('3');
732
+ const statusCode = xRedirectCode && `${xRedirectCode} (${response.status})` || response.status;
733
+ // ---------------
734
+ log.push(`[${style.comment((new Date).toUTCString())}]`);
735
+ log.push(style.keyword(request.method));
736
+ if (isproxy) log.push(style.keyword('>>'));
737
+ log.push(style.url(request.url));
738
+ if (response.meta.hint) log.push(`(${style.comment(response.meta.hint)})`);
739
+ const contentInfo = [response.headers.get('Content-Type'), response.headers.get('Content-Length')].filter(x => x);
740
+ if (contentInfo.length) log.push(`(${style.comment(contentInfo.join('; '))})`);
741
+ if (response.headers.get('Content-Encoding')) log.push(`(${style.comment(response.headers.get('Content-Encoding'))})`);
742
+ if (errorCode) log.push(style.err(`${errorCode} ${response.statusText}`));
743
+ else log.push(style.val(`${statusCode} ${response.statusText}`));
744
+ if (isRedirect) log.push(`- ${style.url(response.headers.get('Location'))}`);
745
+
746
+ return log.join(' ');
747
+ }
748
+
749
+ }
750
+
751
+ const _streamRead = stream => new Promise(res => {
752
+ let data = '';
753
+ stream.on('data', chunk => data += chunk);
754
+ stream.on('end', () => res(data));
755
+ });