what-server 0.6.5 → 0.6.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/src/index.js CHANGED
@@ -119,7 +119,7 @@ function _renderHydratable(vnode) {
119
119
 
120
120
  // Array — wrap in list markers
121
121
  if (Array.isArray(vnode)) {
122
- return `<!--[]-->${vnode.map(_renderHydratable).join('')}<!--/[]-->`;
122
+ return `<!--[]-->${renderChildrenToString(vnode, _renderHydratable)}<!--/[]-->`;
123
123
  }
124
124
 
125
125
  // Component — add hydration key to root element
@@ -150,7 +150,7 @@ function _renderHydratable(vnode) {
150
150
  if (VOID_ELEMENTS.has(tag)) return open;
151
151
 
152
152
  const rawInner = _resolveInnerHTML(props);
153
- const inner = rawInner != null ? String(rawInner) : children.map(_renderHydratable).join('');
153
+ const inner = rawInner != null ? String(rawInner) : renderChildrenToString(children, _renderHydratable);
154
154
  return `${open}${inner}</${tag}>`;
155
155
  }
156
156
 
@@ -198,7 +198,7 @@ export function renderToString(vnode) {
198
198
 
199
199
  // Array
200
200
  if (Array.isArray(vnode)) {
201
- return vnode.map(renderToString).join('');
201
+ return renderChildrenToString(vnode, renderToString);
202
202
  }
203
203
 
204
204
  // Component
@@ -226,10 +226,18 @@ export function renderToString(vnode) {
226
226
  if (VOID_ELEMENTS.has(tag)) return open;
227
227
 
228
228
  const rawInner = _resolveInnerHTML(props);
229
- const inner = rawInner != null ? String(rawInner) : children.map(renderToString).join('');
229
+ const inner = rawInner != null ? String(rawInner) : renderChildrenToString(children, renderToString);
230
230
  return `${open}${inner}</${tag}>`;
231
231
  }
232
232
 
233
+ function renderChildrenToString(children, renderChild) {
234
+ let html = '';
235
+ for (let i = 0; i < children.length; i++) {
236
+ html += renderChild(children[i]);
237
+ }
238
+ return html;
239
+ }
240
+
233
241
  // --- Stream Render ---
234
242
  // Returns an async iterator for streaming SSR.
235
243
 
@@ -424,11 +432,25 @@ function _resolveInnerHTML(props) {
424
432
 
425
433
  function renderAttrs(props) {
426
434
  let out = '';
427
- for (const [key, val] of Object.entries(props)) {
435
+ const keys = Object.keys(props);
436
+ for (let i = 0; i < keys.length; i++) {
437
+ const key = keys[i];
438
+ const val = props[key];
428
439
  if (key === 'key' || key === 'ref' || key === 'children' || key === 'dangerouslySetInnerHTML' || key === 'innerHTML') continue;
429
440
  if (key.startsWith('on') && key.length > 2) continue; // Skip event handlers in SSR
430
441
  if (val === false || val == null) continue;
431
442
 
443
+ const attr = getHtmlAttributeMetadata(key);
444
+ const attrName = attr.name;
445
+ if (!attr.valid) {
446
+ if (_isDevMode) console.warn(`[what-server] Omitted invalid attribute name: ${key}`);
447
+ continue;
448
+ }
449
+ if (!isSafeUrlAttributeValue(attr.urlKind, val)) {
450
+ if (_isDevMode) console.warn(`[what-server] Omitted unsafe URL attribute "${attrName}": ${val}`);
451
+ continue;
452
+ }
453
+
432
454
  if (key === 'className' || key === 'class') {
433
455
  out += ` class="${escapeHtml(String(val))}"`;
434
456
  } else if (key === 'style' && typeof val === 'object') {
@@ -438,19 +460,105 @@ function renderAttrs(props) {
438
460
  out += ` style="${escapeHtml(css)}"`;
439
461
  } else if (val === true) {
440
462
  // ARIA attributes require explicit ="true", HTML boolean attrs can be bare
441
- if (key.startsWith('aria-') || key === 'role') {
442
- out += ` ${key}="true"`;
463
+ if (attrName.startsWith('aria-') || attrName === 'role') {
464
+ out += ` ${attrName}="true"`;
443
465
  } else {
444
- out += ` ${key}`;
466
+ out += ` ${attrName}`;
445
467
  }
446
468
  } else {
447
- out += ` ${key}="${escapeHtml(String(val))}"`;
469
+ out += ` ${attrName}="${escapeHtml(String(val))}"`;
448
470
  }
449
471
  }
450
472
 
451
473
  return out;
452
474
  }
453
475
 
476
+ function getHtmlAttributeName(name) {
477
+ if (name === 'className') return 'class';
478
+ if (name === 'htmlFor') return 'for';
479
+ return name;
480
+ }
481
+
482
+ const ATTR_METADATA_CACHE = new Map();
483
+ const ATTR_METADATA_CACHE_MAX = 2048;
484
+ const URL_ATTR_KIND_NONE = 0;
485
+ const URL_ATTR_KIND_SINGLE = 1;
486
+ const URL_ATTR_KIND_LIST = 2;
487
+
488
+ function getHtmlAttributeMetadata(key) {
489
+ const cached = ATTR_METADATA_CACHE.get(key);
490
+ if (cached) return cached;
491
+
492
+ const name = getHtmlAttributeName(key);
493
+ const valid = isValidHtmlAttributeName(name);
494
+ const meta = {
495
+ name,
496
+ valid,
497
+ urlKind: valid ? getUrlAttributeKind(name) : URL_ATTR_KIND_NONE,
498
+ };
499
+
500
+ if (ATTR_METADATA_CACHE.size >= ATTR_METADATA_CACHE_MAX) ATTR_METADATA_CACHE.clear();
501
+ ATTR_METADATA_CACHE.set(key, meta);
502
+ return meta;
503
+ }
504
+
505
+ function isValidHtmlAttributeName(name) {
506
+ return /^[^\s"'>/=\x00-\x1f\x7f]+$/.test(name);
507
+ }
508
+
509
+ const URL_ATTRS = new Set([
510
+ 'href',
511
+ 'src',
512
+ 'action',
513
+ 'formaction',
514
+ 'poster',
515
+ 'cite',
516
+ 'background',
517
+ 'xlink:href',
518
+ ]);
519
+ const URL_LIST_ATTRS = new Set(['srcset']);
520
+
521
+ function getUrlAttributeKind(name) {
522
+ if (URL_ATTRS.has(name)) return URL_ATTR_KIND_SINGLE;
523
+ if (URL_LIST_ATTRS.has(name)) return URL_ATTR_KIND_LIST;
524
+ if (!hasAsciiUppercase(name)) return URL_ATTR_KIND_NONE;
525
+
526
+ const normalizedName = name.toLowerCase();
527
+ if (URL_ATTRS.has(normalizedName)) return URL_ATTR_KIND_SINGLE;
528
+ if (URL_LIST_ATTRS.has(normalizedName)) return URL_ATTR_KIND_LIST;
529
+ return URL_ATTR_KIND_NONE;
530
+ }
531
+
532
+ function hasAsciiUppercase(str) {
533
+ for (let i = 0; i < str.length; i++) {
534
+ const code = str.charCodeAt(i);
535
+ if (code >= 65 && code <= 90) return true;
536
+ }
537
+ return false;
538
+ }
539
+
540
+ function isSafeUrlAttributeValue(urlKind, value) {
541
+ if (urlKind === URL_ATTR_KIND_LIST) return isSafeSrcsetValue(value);
542
+ if (urlKind === URL_ATTR_KIND_SINGLE) return isSafeUrlValue(value);
543
+ return true;
544
+ }
545
+
546
+ function isSafeUrlValue(value) {
547
+ if (typeof value !== 'string') return true;
548
+ const normalized = value.trim().replace(/[\s\x00-\x1f\x7f]/g, '').toLowerCase();
549
+ return !(normalized.startsWith('javascript:') || normalized.startsWith('data:') || normalized.startsWith('vbscript:'));
550
+ }
551
+
552
+ function isSafeSrcsetValue(value) {
553
+ if (typeof value !== 'string') return true;
554
+ return value
555
+ .split(',')
556
+ .every(candidate => {
557
+ const url = candidate.trim().split(/\s+/, 1)[0] || '';
558
+ return url === '' || isSafeUrlValue(url);
559
+ });
560
+ }
561
+
454
562
  function escapeHtml(str) {
455
563
  return str
456
564
  .replace(/&/g, '&amp;')
package/src/islands.js CHANGED
@@ -89,7 +89,16 @@ export function serializeIslandStores() {
89
89
  for (const [name, store] of sharedStores) {
90
90
  data[name] = store._getSnapshot();
91
91
  }
92
- return JSON.stringify(data);
92
+ return escapeJsonForScript(JSON.stringify(data));
93
+ }
94
+
95
+ function escapeJsonForScript(json) {
96
+ return json
97
+ .replace(/</g, '\\u003C')
98
+ .replace(/>/g, '\\u003E')
99
+ .replace(/&/g, '\\u0026')
100
+ .replace(/\u2028/g, '\\u2028')
101
+ .replace(/\u2029/g, '\\u2029');
93
102
  }
94
103
 
95
104
  // Hydrate shared stores from SSR data