redweb 0.8.0 → 0.10.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 (72) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +573 -307
  3. package/client.d.ts +42 -0
  4. package/client.js +55 -0
  5. package/docs/LIVE_HTML.md +313 -0
  6. package/docs/MULTIPLAYER_OPERATIONS.md +50 -0
  7. package/docs/PRODUCTION_READINESS.md +68 -0
  8. package/docs/VERIFICATION_EVIDENCE.md +20 -0
  9. package/examples/live-html/cards.css +36 -0
  10. package/examples/live-html/cards.html +11 -0
  11. package/examples/live-html/cards.js +91 -0
  12. package/examples/live-html/cards.ts +35 -0
  13. package/examples/live-html/chatroom.css +156 -0
  14. package/examples/live-html/chatroom.js +268 -0
  15. package/examples/live-html/chatroom.ts +217 -0
  16. package/examples/live-html/components.css +7 -0
  17. package/examples/live-html/components.js +113 -0
  18. package/examples/live-html/components.ts +41 -0
  19. package/examples/live-html/counter.css +24 -0
  20. package/examples/live-html/counter.html +10 -0
  21. package/examples/live-html/counter.js +73 -0
  22. package/examples/live-html/counter.ts +21 -0
  23. package/examples/live-html/tsconfig.json +16 -0
  24. package/index.d.ts +538 -114
  25. package/index.js +44 -12
  26. package/package.json +39 -15
  27. package/src/htmx/Html.js +133 -0
  28. package/src/htmx/HtmlRenderer.js +88 -0
  29. package/src/htmx/HtmlSyntax.js +168 -0
  30. package/src/htmx/LiveHtmlServer.js +91 -0
  31. package/src/htmx/LivePage.js +232 -0
  32. package/src/htmx/PageAssetLoader.js +34 -0
  33. package/src/htmx/PageManager.js +435 -0
  34. package/src/htmx/StaticExporter.js +78 -0
  35. package/src/htmx/StaticSite.js +182 -0
  36. package/src/htmx/TemplateRenderer.js +231 -0
  37. package/src/htmx/browserRuntime.js +97 -0
  38. package/src/htmx/index.js +10 -0
  39. package/src/htmx/metadata.js +349 -0
  40. package/src/htmx/sourceRoot.js +28 -0
  41. package/src/htmx/start.js +17 -0
  42. package/src/htmx/synchronous.js +9 -0
  43. package/src/http/BaseHttpServer.js +82 -117
  44. package/src/http/HttpServer.js +18 -18
  45. package/src/http/HttpsServer.js +20 -20
  46. package/src/serverLifecycle.js +46 -46
  47. package/src/ws/AdmissionPolicy.js +145 -0
  48. package/src/ws/BaseHandler.js +40 -40
  49. package/src/ws/BaseSocketServer.js +199 -100
  50. package/src/ws/DefaultHandler.js +5 -5
  51. package/src/ws/DefaultRoute.js +8 -8
  52. package/src/ws/DistributionBridge.js +271 -0
  53. package/src/ws/FixedStepService.js +74 -0
  54. package/src/ws/HeartbeatMonitor.js +75 -0
  55. package/src/ws/Metrics.js +34 -0
  56. package/src/ws/ProtocolPolicy.js +130 -0
  57. package/src/ws/RoomRegistry.js +117 -0
  58. package/src/ws/RouteRuntime.js +146 -0
  59. package/src/ws/SecureSocketServer.js +9 -9
  60. package/src/ws/SessionRegistry.js +135 -0
  61. package/src/ws/SocketRoute.js +523 -254
  62. package/src/ws/SocketServer.js +8 -8
  63. package/src/ws/TaskQueue.js +64 -0
  64. package/src/ws/TokenBucket.js +31 -0
  65. package/src/ws/TransportPolicy.js +68 -0
  66. package/src/ws/index.js +7 -2
  67. package/src/ws/protocol-schema.json +13 -0
  68. package/src/ws/protocol-validation.js +21 -0
  69. package/src/ws/shutdown.js +33 -33
  70. package/src/ws/util.js +38 -30
  71. package/src/htmx/HtmxRenderer.js +0 -73
  72. package/src/htmx/RedWebHtmxComponent.js +0 -11
package/index.js CHANGED
@@ -1,23 +1,55 @@
1
- const { BaseHttpServer, METHODS } = require('./src/http');
2
- const { ENCODINGS, HTTP_OPTIONS } = require('./src/http/BaseHttpServer');
1
+ const { BaseHttpServer, METHODS } = require('./src/http');
2
+ const { ENCODINGS, HTTP_OPTIONS } = require('./src/http/BaseHttpServer');
3
3
  const { sendJson } = require('./src/ws/util');
4
- const { SocketServer, SecureSocketServer, SOCKET_OPTIONS, SocketRoute, SocketService, SocketRegistry } = require('./src/ws');
4
+ const {
5
+ SocketServer,
6
+ SecureSocketServer,
7
+ SOCKET_OPTIONS,
8
+ SocketRoute,
9
+ SocketService,
10
+ FixedStepService,
11
+ SocketRegistry,
12
+ RoomRegistry,
13
+ SessionRegistry,
14
+ ERROR_CODES,
15
+ } = require('./src/ws');
5
16
  const { BaseHandler } = require('./src/ws/BaseHandler');
6
17
  const HttpServer = require('./src/http/HttpServer');
7
18
  const HttpsServer = require('./src/http/HttpsServer');
19
+ const { action, attribute, codeBlock, component, defineSite, each, exportStatic, html, HtmlRenderer, LiveHtmlServer, LivePage, page, start, state, url, view } = require('./src/htmx');
8
20
  module.exports = {
9
- HttpServer,
10
- HttpsServer,
11
- BaseHttpServer,
12
- SocketServer,
21
+ HttpServer,
22
+ HttpsServer,
23
+ BaseHttpServer,
24
+ SocketServer,
13
25
  SecureSocketServer,
14
26
  BaseHandler,
15
27
  SocketRoute,
16
28
  SocketService,
29
+ FixedStepService,
17
30
  SocketRegistry,
31
+ RoomRegistry,
32
+ SessionRegistry,
33
+ ERROR_CODES,
18
34
  sendJson,
19
- SOCKET_OPTIONS,
20
- HTTP_OPTIONS,
21
- ENCODINGS,
22
- METHODS
23
- };
35
+ SOCKET_OPTIONS,
36
+ HTTP_OPTIONS,
37
+ ENCODINGS,
38
+ METHODS,
39
+ action,
40
+ attribute,
41
+ codeBlock,
42
+ component,
43
+ defineSite,
44
+ each,
45
+ exportStatic,
46
+ html,
47
+ HtmlRenderer,
48
+ LiveHtmlServer,
49
+ LivePage,
50
+ page,
51
+ start,
52
+ state,
53
+ url,
54
+ view
55
+ };
package/package.json CHANGED
@@ -1,31 +1,55 @@
1
1
  {
2
2
  "name": "redweb",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "A way to quickly set up an express server",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
7
- "scripts": {
8
- "pretest": "tsc -p tests/types/tsconfig.json",
9
- "test": "npx jest"
7
+ "scripts": {
8
+ "pretest": "node scripts/build-live-html-examples.js --check && node scripts/generate-protocol-types.js --check && tsc -p tests/types/tsconfig.json && tsc -p tests/types/tsconfig.standard.json",
9
+ "prepack": "node scripts/build-live-html-examples.js --check",
10
+ "example:counter": "node examples/live-html/counter.js",
11
+ "example:chatroom": "node examples/live-html/chatroom.js",
12
+ "example:cards": "node examples/live-html/cards.js",
13
+ "example:components": "node examples/live-html/components.js",
14
+ "generate:protocol-types": "node scripts/generate-protocol-types.js",
15
+ "verify:overhead": "node scripts/verify-disabled-overhead.js",
16
+ "verify:soak": "node --expose-gc scripts/verify-soak.js",
17
+ "verify:memory": "node scripts/verify-memory-overhead.js",
18
+ "verify:load": "node scripts/verify-load.js",
19
+ "verify:recovery": "node --expose-gc scripts/verify-recovery.js",
20
+ "verify:live-html": "node scripts/build-live-html-examples.js --check && npx jest tests/integration/live-html.integration.test.js --runInBand --coverage=false",
21
+ "verify:live-html:browser": "node scripts/build-live-html-examples.js --check && node scripts/verify-live-html-browser.js",
22
+ "verify:live-html:load": "node scripts/build-live-html-examples.js --check && node --expose-gc scripts/verify-live-html-load.js",
23
+ "verify:live-html:package": "node scripts/verify-live-html-package.js",
24
+ "test": "npx jest"
10
25
  },
11
26
  "files": [
12
27
  "src/*",
13
- "index.d.ts"
28
+ "client.js",
29
+ "client.d.ts",
30
+ "CHANGELOG.md",
31
+ "index.d.ts",
32
+ "docs",
33
+ "examples/live-html"
14
34
  ],
15
35
  "keywords": [],
16
36
  "author": "",
17
37
  "license": "ISC",
18
- "dependencies": {
19
- "@types/express": "^4.17.21",
20
- "@types/cors": "2.8.19",
21
- "@types/node": "20.19.24",
22
- "@types/ws": "^8.18.1",
23
- "cors": "^2.8.5",
24
- "express": "^4.19.2",
25
- "ws": "^8.17.0"
38
+ "engines": {
39
+ "node": ">=18"
26
40
  },
27
- "devDependencies": {
28
- "@types/jest": "^29.5.12",
41
+ "dependencies": {
42
+ "@types/cors": "2.8.19",
43
+ "@types/express": "^4.17.21",
44
+ "@types/node": "20.19.24",
45
+ "@types/ws": "^8.18.1",
46
+ "cors": "^2.8.5",
47
+ "express": "^4.22.2",
48
+ "redweb-client": "^0.1.0",
49
+ "ws": "^8.21.3"
50
+ },
51
+ "devDependencies": {
52
+ "@types/jest": "^29.5.12",
29
53
  "jest": "^29.7.0",
30
54
  "supertest": "^7.0.0",
31
55
  "typescript": "^5.9.3"
@@ -0,0 +1,133 @@
1
+ const HTML_FRAGMENT = Symbol('redweb.htmlFragment');
2
+ const HTML_ATTRIBUTE = Symbol('redweb.htmlAttribute');
3
+ const HTML_URL = Symbol('redweb.htmlUrl');
4
+ const HTML_RENDERERS = new WeakMap();
5
+ const URL_ATTRIBUTES = new Set(['action', 'background', 'cite', 'data', 'formaction', 'href', 'manifest', 'ping', 'poster', 'src', 'xlink:href']);
6
+ const FORBIDDEN_ATTRIBUTES = new Set(['srcdoc', 'srcset', 'style']);
7
+ const { interpolationContext } = require('./HtmlSyntax');
8
+ const synchronous = require('./synchronous');
9
+
10
+ function escapeHtml(value) {
11
+ return String(value ?? '')
12
+ .replaceAll('&', '&')
13
+ .replaceAll('<', '&lt;')
14
+ .replaceAll('>', '&gt;')
15
+ .replaceAll('"', '&quot;')
16
+ .replaceAll("'", '&#39;');
17
+ }
18
+
19
+ function isHtml(value) {
20
+ if (Array.isArray(value)) return value.every(isHtml);
21
+ return Boolean(value?.[HTML_FRAGMENT]) || Boolean(value && typeof value === 'object' && HTML_RENDERERS.has(value));
22
+ }
23
+
24
+ function markHtml(value, toString) {
25
+ HTML_RENDERERS.set(value, toString);
26
+ return value;
27
+ }
28
+
29
+ function trustedHtml(value) {
30
+ const fragment = {};
31
+ markHtml(fragment, () => String(value));
32
+ return Object.freeze(fragment);
33
+ }
34
+
35
+ function trustedValue(brand, value) {
36
+ return Object.freeze({ [brand]: true, value: String(value) });
37
+ }
38
+
39
+ function attribute(value) {
40
+ if (!['string', 'number', 'bigint', 'boolean'].includes(typeof value)) {
41
+ throw new TypeError('attribute() requires a string, number, bigint, or boolean.');
42
+ }
43
+ return trustedValue(HTML_ATTRIBUTE, value);
44
+ }
45
+
46
+ function safeUrl(value) {
47
+ if (typeof value !== 'string' || !value || value !== value.trim() || /[\u0000-\u001f\u007f]/.test(value)) {
48
+ throw new TypeError('url() requires a non-empty URL without surrounding whitespace or control characters.');
49
+ }
50
+ if (value.startsWith('//') || value.includes('\\')) throw new TypeError('url() does not allow protocol-relative or backslash URLs.');
51
+ const protocol = /^[A-Za-z][A-Za-z0-9+.-]*:/.exec(value)?.[0].toLowerCase();
52
+ if (protocol && !['http:', 'https:', 'mailto:', 'tel:'].includes(protocol)) {
53
+ throw new TypeError(`url() does not allow the ${protocol} protocol.`);
54
+ }
55
+ return trustedValue(HTML_URL, value);
56
+ }
57
+
58
+ function renderValue(value) {
59
+ if (Array.isArray(value)) {
60
+ if (!isHtml(value)) throw new TypeError('Only arrays of HtmlFragment values can be rendered as HTML.');
61
+ return value.map(renderValue).join('');
62
+ }
63
+ if (HTML_RENDERERS.has(value)) return HTML_RENDERERS.get(value).call(value);
64
+ return isHtml(value) ? value.toString() : escapeHtml(value);
65
+ }
66
+
67
+ function renderInterpolation(source, value) {
68
+ const context = interpolationContext(source);
69
+ if (context.kind === 'attribute') {
70
+ const name = context.name;
71
+ if (name.startsWith('on') || FORBIDDEN_ATTRIBUTES.has(name)) {
72
+ throw new TypeError(`Dynamic ${name} attributes are not allowed.`);
73
+ }
74
+ if (URL_ATTRIBUTES.has(name)) {
75
+ if (!value?.[HTML_URL]) {
76
+ if (value?.[HTML_ATTRIBUTE]) throw new TypeError(`The ${name} attribute requires url().`);
77
+ value = safeUrl(value);
78
+ }
79
+ } else if (!value?.[HTML_ATTRIBUTE]) {
80
+ if (value?.[HTML_URL]) throw new TypeError(`The ${name} attribute requires attribute().`);
81
+ if (isHtml(value)) throw new TypeError(`The ${name} attribute requires a primitive value.`);
82
+ value = attribute(value);
83
+ }
84
+ return escapeHtml(value.value);
85
+ }
86
+ if (context.kind !== 'text') throw new TypeError('html interpolations are only allowed in element text.');
87
+ if (value?.[HTML_ATTRIBUTE] || value?.[HTML_URL]) {
88
+ throw new TypeError('attribute() and url() values may only be used in matching quoted attributes.');
89
+ }
90
+ return renderValue(value);
91
+ }
92
+
93
+ function html(strings, ...values) {
94
+ if (!Array.isArray(strings) || !Object.prototype.hasOwnProperty.call(strings, 'raw')) {
95
+ throw new TypeError('html must be used as a tagged template literal.');
96
+ }
97
+ const rendered = strings.reduce((result, part, index) => {
98
+ const next = result + part;
99
+ if (index >= values.length) return next;
100
+ return next + renderInterpolation(next, values[index]);
101
+ }, '');
102
+ return Object.freeze({ [HTML_FRAGMENT]: true, toString: () => rendered });
103
+ }
104
+
105
+ function each(items, render) {
106
+ if (!Array.isArray(items)) throw new TypeError('each() requires an array.');
107
+ if (typeof render !== 'function') throw new TypeError('each() requires a render function.');
108
+ const fragments = items.map((item, index) => render(item, index));
109
+ if (!fragments.every(isHtml)) throw new TypeError('each() render functions must return html fragments.');
110
+ return Object.freeze({ [HTML_FRAGMENT]: true, toString: () => fragments.map(renderValue).join('') });
111
+ }
112
+
113
+ function codeBlock(code, options = {}) {
114
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
115
+ throw new TypeError('codeBlock() options must be an object.');
116
+ }
117
+ const { language = 'text', label = language, highlight } = options;
118
+ if (typeof language !== 'string' || !/^[A-Za-z0-9_+-]{1,32}$/.test(language)) {
119
+ throw new TypeError('codeBlock() language must be a safe name of at most 32 characters.');
120
+ }
121
+ if (typeof label !== 'string') throw new TypeError('codeBlock() label must be a string.');
122
+ if (highlight !== undefined && typeof highlight !== 'function') throw new TypeError('codeBlock() highlight must be a function.');
123
+ const caption = label ? html`<figcaption>${label}</figcaption>` : html``;
124
+ let content = isHtml(code) ? code : String(code ?? '');
125
+ if (highlight) {
126
+ if (isHtml(code)) throw new TypeError('codeBlock() cannot highlight an HtmlFragment.');
127
+ content = synchronous(highlight(content, language), 'codeBlock() highlight must render synchronously.');
128
+ if (!isHtml(content)) throw new TypeError('codeBlock() highlight must return an HtmlFragment.');
129
+ }
130
+ return html`<figure class="redweb-code">${caption}<pre><code class="${attribute(`language-${language}`)}">${content}</code></pre></figure>`;
131
+ }
132
+
133
+ module.exports = { attribute, codeBlock, each, escapeHtml, html, isHtml, markHtml, renderValue, safeUrl, trustedHtml };
@@ -0,0 +1,88 @@
1
+ const { escapeHtml, isHtml, renderValue } = require('./Html');
2
+ const PageAssetLoader = require('./PageAssetLoader');
3
+ const TemplateRenderer = require('./TemplateRenderer');
4
+ const { getStateConfig, getViewMetadata } = require('./metadata');
5
+
6
+ function serializeJson(value) {
7
+ return JSON.stringify(value).replaceAll('<', '\\u003c');
8
+ }
9
+
10
+ function meta(attribute, name, content) {
11
+ return `<meta ${attribute}="${escapeHtml(name)}" content="${escapeHtml(content)}">`;
12
+ }
13
+
14
+ class HtmlRenderer {
15
+ static file(filePath, rootDir, kind) {
16
+ return new PageAssetLoader().load(filePath, rootDir, kind).content;
17
+ }
18
+
19
+ static template(filePath, rootDir) {
20
+ return HtmlRenderer.file(filePath, rootDir, 'template');
21
+ }
22
+
23
+ static stylesheet(filePath, rootDir) {
24
+ return HtmlRenderer.file(filePath, rootDir, 'stylesheet');
25
+ }
26
+
27
+ static render(source, page, options = {}) {
28
+ if (typeof source !== 'string') throw new TypeError('Page markup must be a string.');
29
+ if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Render options must be an object.');
30
+ const { live = true } = options;
31
+ if (typeof live !== 'boolean') throw new TypeError('Render live must be a boolean.');
32
+ return new TemplateRenderer(source, page, HtmlRenderer.collection, live).render();
33
+ }
34
+
35
+ static collection(page, name, value) {
36
+ if (!getStateConfig(page.constructor, name)) throw new Error(`Page collection "${name}" is missing @state metadata.`);
37
+ if (!Array.isArray(value)) throw new TypeError(`Page collection "${name}" must be an array.`);
38
+ const view = getViewMetadata(page.constructor, name);
39
+ if (!view) throw new Error(`Page collection "${name}" is missing @view metadata.`);
40
+ if (page[view.method] !== view.implementation) throw new Error(`View for page collection "${name}" was replaced.`);
41
+ return value.map((item, index) => {
42
+ const rendered = view.implementation.call(page, item, index);
43
+ if (!isHtml(rendered)) throw new TypeError(`View for page collection "${name}" must return html.`);
44
+ return renderValue(rendered);
45
+ }).join('');
46
+ }
47
+
48
+ static statePayload(name, value, page) {
49
+ if (page && getViewMetadata(page.constructor, name)) {
50
+ return { name, value: HtmlRenderer.collection(page, name, value), html: true };
51
+ }
52
+ return { name, value: isHtml(value) ? renderValue(value) : String(value ?? ''), html: isHtml(value) };
53
+ }
54
+
55
+ static head(metadata = {}) {
56
+ const tags = [];
57
+ if (metadata.title) tags.push(`<title>${escapeHtml(metadata.title)}</title>`, meta('property', 'og:title', metadata.title), meta('name', 'twitter:title', metadata.title));
58
+ if (metadata.description) tags.push(meta('name', 'description', metadata.description), meta('property', 'og:description', metadata.description), meta('name', 'twitter:description', metadata.description));
59
+ if (metadata.canonical) tags.push(`<link rel="canonical" href="${escapeHtml(metadata.canonical)}">`, meta('property', 'og:url', metadata.canonical));
60
+ if (metadata.image) tags.push(meta('property', 'og:image', metadata.image), meta('name', 'twitter:image', metadata.image));
61
+ if (metadata.robots) tags.push(meta('name', 'robots', metadata.robots));
62
+ if (metadata.title || metadata.description || metadata.image) tags.push(meta('name', 'twitter:card', metadata.image ? 'summary_large_image' : 'summary'));
63
+ return tags.join('');
64
+ }
65
+
66
+ static document(markup, config = null, stylesheets = [], metadata = {}) {
67
+ const bootstrap = config ? `<script type="application/json" id="__redweb_page">${serializeJson(config)}</script>` +
68
+ `<script type="module" src="${escapeHtml(config.runtimePath)}"></script>` : '';
69
+ const links = stylesheets.map(href => `<link rel="stylesheet" href="${escapeHtml(href)}">`).join('');
70
+ const headMarkup = HtmlRenderer.head(metadata) + links;
71
+ const body = TemplateRenderer.closingTag(markup, 'body');
72
+ if (body >= 0) {
73
+ const head = TemplateRenderer.closingTag(markup, 'head');
74
+ const insertions = [{ position: body, value: bootstrap }];
75
+ if (head >= 0) {
76
+ if (headMarkup) insertions.push({ position: head, value: headMarkup });
77
+ } else if (headMarkup) {
78
+ const bodyOpen = TemplateRenderer.openingTag(markup, 'body');
79
+ insertions.push({ position: Math.max(0, bodyOpen), value: `<head>${headMarkup}</head>` });
80
+ }
81
+ return insertions.sort((left, right) => right.position - left.position)
82
+ .reduce((result, insertion) => result.slice(0, insertion.position) + insertion.value + result.slice(insertion.position), markup);
83
+ }
84
+ return `<!doctype html><html><head>${headMarkup}</head><body><main data-rw-root>${markup}</main>${bootstrap}</body></html>`;
85
+ }
86
+ }
87
+
88
+ module.exports = HtmlRenderer;
@@ -0,0 +1,168 @@
1
+ const RAW_TEXT = new Set(['iframe', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'textarea', 'title', 'xmp']);
2
+
3
+ function isHtmlSpace(character) {
4
+ return character === ' ' || character === '\t' || character === '\n' || character === '\f' || character === '\r';
5
+ }
6
+
7
+ function isNonStartMarkup(source, start) {
8
+ const marker = source[start + 1];
9
+ if (marker === '!' || marker === '?') return true;
10
+ return marker === '/' && /[A-Za-z]/.test(source[start + 2]);
11
+ }
12
+
13
+ function equalsAsciiCaseInsensitive(value, expected) {
14
+ if (value.length !== expected.length) return false;
15
+ for (let index = 0; index < value.length; index += 1) {
16
+ const code = value.charCodeAt(index);
17
+ const folded = code >= 65 && code <= 90 ? code + 32 : code;
18
+ if (folded !== expected.charCodeAt(index)) return false;
19
+ }
20
+ return true;
21
+ }
22
+
23
+ function scanTag(source, position) {
24
+ let state = 'beforeAttribute';
25
+ let quote;
26
+ let attributeName = null;
27
+ let attributeStart = -1;
28
+ for (; position < source.length; position += 1) {
29
+ const character = source[position];
30
+ if (state === 'quotedValue') {
31
+ if (character === quote) state = 'beforeAttribute';
32
+ continue;
33
+ }
34
+ if (character === '>') return { end: position, state, attributeName };
35
+ if (state === 'beforeValue') {
36
+ if (isHtmlSpace(character)) continue;
37
+ if (character === '"' || character === "'") {
38
+ quote = character;
39
+ state = 'quotedValue';
40
+ } else state = 'unquotedValue';
41
+ } else if (state === 'beforeAttribute') {
42
+ if (!isHtmlSpace(character) && character !== '/') {
43
+ attributeStart = position;
44
+ state = 'attributeName';
45
+ }
46
+ } else if (state === 'attributeName') {
47
+ if (character === '=') {
48
+ attributeName = source.slice(attributeStart, position).toLowerCase();
49
+ state = 'beforeValue';
50
+ } else if (isHtmlSpace(character)) {
51
+ attributeName = source.slice(attributeStart, position).toLowerCase();
52
+ state = 'afterAttributeName';
53
+ }
54
+ } else if (state === 'afterAttributeName') {
55
+ if (character === '=') state = 'beforeValue';
56
+ else if (!isHtmlSpace(character) && character !== '/') {
57
+ attributeStart = position;
58
+ attributeName = null;
59
+ state = 'attributeName';
60
+ }
61
+ } else if (isHtmlSpace(character)) state = 'beforeAttribute';
62
+ }
63
+ return { end: -1, state, attributeName };
64
+ }
65
+
66
+ function tagEnd(source, position) {
67
+ return scanTag(source, position).end;
68
+ }
69
+
70
+ function rawClosingTag(source, name, position) {
71
+ while (true) {
72
+ const start = source.indexOf('</', position);
73
+ if (start < 0) return null;
74
+ const candidate = source.slice(start + 2, start + 2 + name.length);
75
+ const boundary = source[start + 2 + name.length];
76
+ if (equalsAsciiCaseInsensitive(candidate, name) && (boundary === '>' || boundary === '/' || isHtmlSpace(boundary))) {
77
+ const end = tagEnd(source, start + 2 + name.length);
78
+ if (end >= 0) return { start, end: end + 1 };
79
+ }
80
+ position = start + 2;
81
+ }
82
+ }
83
+
84
+ function tagLocation(source, target, kind) {
85
+ let position = 0;
86
+ while (position < source.length) {
87
+ const start = source.indexOf('<', position);
88
+ if (start < 0) return -1;
89
+ if (source.startsWith('<!--', start)) {
90
+ const commentEnd = source.indexOf('-->', start + 4);
91
+ position = commentEnd < 0 ? source.length : commentEnd + 3;
92
+ continue;
93
+ }
94
+ const recognizedMarkup = isNonStartMarkup(source, start);
95
+ if (!recognizedMarkup && !/[A-Za-z]/.test(source[start + 1])) {
96
+ position = start + 1;
97
+ continue;
98
+ }
99
+ const end = tagEnd(source, start + 1);
100
+ if (end < 0) return -1;
101
+ const tag = source.slice(start, end + 1);
102
+ const closing = /^<\/([A-Za-z][\w:-]*)/i.exec(tag)?.[1]?.toLowerCase();
103
+ if (kind === 'closing' && closing === target) return start;
104
+ const opening = /^<([A-Za-z][\w:-]*)/i.exec(tag)?.[1]?.toLowerCase();
105
+ if (kind === 'opening' && opening === target) return start;
106
+ if (opening && RAW_TEXT.has(opening)) {
107
+ if (opening === 'plaintext') return -1;
108
+ const close = rawClosingTag(source, opening, end + 1);
109
+ position = close ? close.end : source.length;
110
+ } else {
111
+ position = end + 1;
112
+ }
113
+ }
114
+ return -1;
115
+ }
116
+
117
+ function interpolationContext(source) {
118
+ let position = 0;
119
+ while (position < source.length) {
120
+ const start = source.indexOf('<', position);
121
+ if (start < 0) return { kind: 'text' };
122
+ if (source.startsWith('<!--', start)) {
123
+ const commentEnd = source.indexOf('-->', start + 4);
124
+ if (commentEnd < 0) return { kind: 'protected' };
125
+ position = commentEnd + 3;
126
+ continue;
127
+ }
128
+ const opening = /^<([A-Za-z][\w:-]*)/.exec(source.slice(start));
129
+ if (opening) {
130
+ const name = opening[1].toLowerCase();
131
+ const scanned = scanTag(source, start + opening[0].length);
132
+ if (scanned.end < 0) {
133
+ if (scanned.state === 'quotedValue' && scanned.attributeName) {
134
+ return { kind: 'attribute', name: scanned.attributeName };
135
+ }
136
+ return { kind: 'protected' };
137
+ }
138
+ if (RAW_TEXT.has(name)) {
139
+ if (name === 'plaintext') return { kind: 'protected' };
140
+ const close = rawClosingTag(source, name, scanned.end + 1);
141
+ if (!close) return { kind: 'protected' };
142
+ position = close.end;
143
+ } else {
144
+ position = scanned.end + 1;
145
+ }
146
+ continue;
147
+ }
148
+ if (isNonStartMarkup(source, start)) {
149
+ const end = tagEnd(source, start + 1);
150
+ if (end < 0) return { kind: 'protected' };
151
+ position = end + 1;
152
+ } else {
153
+ position = start + 1;
154
+ }
155
+ }
156
+ return { kind: 'text' };
157
+ }
158
+
159
+ module.exports = {
160
+ RAW_TEXT,
161
+ closingTag: (source, target) => tagLocation(source, target, 'closing'),
162
+ interpolationContext,
163
+ isHtmlSpace,
164
+ isNonStartMarkup,
165
+ openingTag: (source, target) => tagLocation(source, target, 'opening'),
166
+ rawClosingTag,
167
+ tagEnd,
168
+ };
@@ -0,0 +1,91 @@
1
+ const express = require('express');
2
+ const HttpServer = require('../http/HttpServer');
3
+ const HttpsServer = require('../http/HttpsServer');
4
+ const SocketServer = require('../ws/SocketServer');
5
+ const { PageManager } = require('./PageManager');
6
+
7
+ class LiveHtmlServer {
8
+ constructor(options = {}) {
9
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
10
+ throw new TypeError('Live HTML server options must be an object.');
11
+ }
12
+ const {
13
+ pages,
14
+ templateRoot,
15
+ livePaths,
16
+ sessionTtlMs,
17
+ maxSessions,
18
+ maxConcurrentRenders,
19
+ shutdownTimeoutMs = 1000,
20
+ heartbeat,
21
+ authenticate,
22
+ origins,
23
+ server: suppliedApp,
24
+ ...httpOptions
25
+ } = options;
26
+ const app = suppliedApp === undefined ? express() : suppliedApp;
27
+ if (!app || typeof app.get !== 'function' || typeof app.use !== 'function') {
28
+ throw new TypeError('`server` must be an Express-compatible application.');
29
+ }
30
+ this.manager = new PageManager({
31
+ pages,
32
+ templateRoot,
33
+ paths: livePaths,
34
+ sessionTtlMs,
35
+ maxSessions,
36
+ maxConcurrentRenders,
37
+ shutdownTimeoutMs,
38
+ heartbeat,
39
+ authenticate,
40
+ origins,
41
+ logger: httpOptions.logger,
42
+ });
43
+ this.manager.mount(app);
44
+ const listen = httpOptions.listen ?? true;
45
+ const ServerClass = httpOptions.ssl ? HttpsServer : HttpServer;
46
+ this.http = new ServerClass({ ...httpOptions, server: app, listen: this.manager.hasLivePages ? false : listen });
47
+ if (this.manager.hasLivePages) {
48
+ const Route = this.manager.route();
49
+ this.sockets = new SocketServer({
50
+ server: this.http.server,
51
+ routes: [Route],
52
+ listen,
53
+ port: this.http.port,
54
+ bind: this.http.bind,
55
+ listenCallback: this.http.listenCallback,
56
+ logger: this.http.logger,
57
+ closeServerOnShutdown: false,
58
+ });
59
+ } else {
60
+ this.sockets = null;
61
+ }
62
+ this.app = this.http.app;
63
+ this.server = this.http.server;
64
+ this._shutdownPromise = null;
65
+ }
66
+
67
+ shutdown() {
68
+ if (!this._shutdownPromise) {
69
+ this._shutdownPromise = this.performShutdown();
70
+ }
71
+ return this._shutdownPromise;
72
+ }
73
+
74
+ async performShutdown() {
75
+ const errors = [];
76
+ if (this.sockets) {
77
+ try { await this.sockets.shutdown(); }
78
+ catch (error) { errors.push(error); }
79
+ }
80
+ try { await this.manager.shutdown(); }
81
+ catch (error) {
82
+ errors.push(error);
83
+ if (error?.code === 'LIVE_HTML_SHUTDOWN_TIMEOUT') this.server.closeAllConnections?.();
84
+ }
85
+ try { await this.http.shutdown(); }
86
+ catch (error) { errors.push(error); }
87
+ if (errors.length) throw new AggregateError(errors, 'Live HTML shutdown failed.');
88
+ }
89
+ }
90
+
91
+ module.exports = LiveHtmlServer;