@webjsdev/core 0.7.48 → 0.7.49
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/README.md +3 -2
- package/dist/webjs-core-browser.js +4 -4
- package/dist/webjs-core-browser.js.map +2 -2
- package/dist/webjs-core-testing.js +1 -1
- package/dist/webjs-core-testing.js.map +1 -1
- package/dist/webjs-core.js +5 -5
- package/dist/webjs-core.js.map +2 -2
- package/index-browser.js +12 -4
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/component.js +94 -2
- package/src/render-server.js +40 -9
- package/src/router-client.js +28 -10
- package/src/websocket-client.js +5 -2
package/index-browser.js
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* Stripped:
|
|
9
9
|
* - `renderToString`, `renderToStream` (server SSR pipeline; reach
|
|
10
10
|
* for `@webjsdev/core/server` if you need them in a Node test).
|
|
11
|
-
* - `setCspNonceProvider`
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* - `setCspNonceProvider`, `setAssetUrlProvider` and
|
|
12
|
+
* `setFormActionResolver` (server-side wiring called by
|
|
13
|
+
* `@webjsdev/server`; the reader halves stay, because a layout
|
|
14
|
+
* can call `cspNonce()` / `asset()`).
|
|
14
15
|
*
|
|
15
16
|
* The framework's own SSR pipeline runs on Node and resolves the
|
|
16
17
|
* package via the package.json `"default"` condition, which still
|
|
@@ -52,7 +53,14 @@ export {
|
|
|
52
53
|
// the re-export still also evaluates the module, so the two are belt-and-
|
|
53
54
|
// suspenders, not a double-enable (ESM evaluates once, enable is idempotent).
|
|
54
55
|
import './src/router-client.js';
|
|
55
|
-
|
|
56
|
+
// `loadFrame` is here (and in `./index.js`, keeping the two symmetric) because
|
|
57
|
+
// `@webjsdev/core/client-router`'s `default` condition points at THIS bundle, so
|
|
58
|
+
// in dist mode the subpath resolves here while its published `types`
|
|
59
|
+
// (`src/router-client.d.ts`) declare all five public router functions. Without
|
|
60
|
+
// it, `import { loadFrame } from '@webjsdev/core/client-router'` type-checks,
|
|
61
|
+
// works in dev (where the subpath maps to `src/router-client.js`) and is
|
|
62
|
+
// `undefined` in production.
|
|
63
|
+
export { enableClientRouter, disableClientRouter, navigate, revalidate, loadFrame } from './src/router-client.js';
|
|
56
64
|
export { WebjsFrame } from './src/webjs-frame.js';
|
|
57
65
|
export { WebjsStream, renderStream } from './src/webjs-stream.js';
|
|
58
66
|
|
package/index.d.ts
CHANGED
|
@@ -81,7 +81,7 @@ export { repeat, isRepeat } from './src/repeat.js';
|
|
|
81
81
|
export { Suspense, isSuspense } from './src/suspense.js';
|
|
82
82
|
export { connectWS } from './src/websocket-client.js';
|
|
83
83
|
export { richFetch } from './src/rich-fetch.js';
|
|
84
|
-
export { enableClientRouter, disableClientRouter, revalidate } from './src/router-client.js';
|
|
84
|
+
export { enableClientRouter, disableClientRouter, revalidate, loadFrame } from './src/router-client.js';
|
|
85
85
|
|
|
86
86
|
// `navigate` is typed against the generated `Route` union (#258) rather than
|
|
87
87
|
// the JSDoc `string`. Until an app runs `webjs types`, `Route` resolves to
|
package/index.js
CHANGED
|
@@ -28,7 +28,7 @@ export {
|
|
|
28
28
|
stringify, parse,
|
|
29
29
|
serialize, deserialize,
|
|
30
30
|
} from './src/serialize.js';
|
|
31
|
-
export { enableClientRouter, disableClientRouter, navigate, revalidate } from './src/router-client.js';
|
|
31
|
+
export { enableClientRouter, disableClientRouter, navigate, revalidate, loadFrame } from './src/router-client.js';
|
|
32
32
|
export { WebjsFrame } from './src/webjs-frame.js';
|
|
33
33
|
export { WebjsStream, renderStream } from './src/webjs-stream.js';
|
|
34
34
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.49",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The runtime for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Ships the html and css template tags, the WebComponent base class, signals, directives, and the isomorphic renderers.",
|
|
6
6
|
"types": "./index.d.ts",
|
package/src/component.js
CHANGED
|
@@ -157,6 +157,42 @@ function warnFunctionReflection(host, propName, attrName) {
|
|
|
157
157
|
);
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Warn that a value with no JSON representation was dropped rather than
|
|
162
|
+
* reflected (#1253).
|
|
163
|
+
*
|
|
164
|
+
* `JSON.stringify` throws for three reasons an app hits in practice: a cycle,
|
|
165
|
+
* a `BigInt`, and an author `toJSON()` that throws. All three mean the same
|
|
166
|
+
* thing here, that there is no string to put in the attribute, so all three
|
|
167
|
+
* get the same answer as a function does.
|
|
168
|
+
*
|
|
169
|
+
* The caught message IS included, unlike `warnFunctionReflection`, which
|
|
170
|
+
* withholds its value on purpose. A function's string form is its source,
|
|
171
|
+
* which is the leak that guard exists to prevent; `JSON.stringify`'s own
|
|
172
|
+
* message names the property path rather than a value, and the sibling
|
|
173
|
+
* `.prop=${val}` SSR drop in `render-server.js` already ends the same way.
|
|
174
|
+
*
|
|
175
|
+
* UNCONDITIONAL, for the reason recorded on `warnFunctionReflection` above: a
|
|
176
|
+
* `NODE_ENV` gate is folded to a constant by the dist build, which would make
|
|
177
|
+
* the SSR half unreachable in every published build, and the SSR half is where
|
|
178
|
+
* the component silently vanishes.
|
|
179
|
+
*
|
|
180
|
+
* @param {{ constructor: unknown, tagName?: string }} host
|
|
181
|
+
* @param {string} propName
|
|
182
|
+
* @param {string} attrName
|
|
183
|
+
* @param {string} [detail] the message `JSON.stringify` threw
|
|
184
|
+
*/
|
|
185
|
+
function warnUnserializableReflection(host, propName, attrName, detail) {
|
|
186
|
+
if (typeof console === 'undefined' || !console.warn) return;
|
|
187
|
+
const tag = tagOf(/** @type any */ (host.constructor)) || host.tagName?.toLowerCase() || 'unknown';
|
|
188
|
+
console.warn(
|
|
189
|
+
`[webjs] reflect:true property "${propName}" on <${tag}> holds a value `
|
|
190
|
+
+ `JSON.stringify cannot serialize (a cycle, a BigInt, or a throwing `
|
|
191
|
+
+ `toJSON), so it has no HTML attribute representation. Removing `
|
|
192
|
+
+ `"${attrName}" instead. Detail: ${detail}`
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
160
196
|
/**
|
|
161
197
|
* A minimal base for HTML Custom Elements that mirrors Lit's ergonomics
|
|
162
198
|
* while staying JSDoc-only and no-build.
|
|
@@ -745,12 +781,40 @@ class WebComponentBase extends Base {
|
|
|
745
781
|
} else if (value == null) {
|
|
746
782
|
this.removeAttribute(attrName);
|
|
747
783
|
} else if (decl.type === Object || decl.type === Array) {
|
|
784
|
+
// One rule about an unserializable reflected value, in two halves.
|
|
785
|
+
//
|
|
748
786
|
// A JSON-typed prop CARRYING a function is safe and stays whole:
|
|
749
787
|
// `JSON.stringify` drops a function to `null` in an array and omits
|
|
750
788
|
// the key in an object, so `[1, 2, fn]` serializes to `[1,2,null]`
|
|
751
789
|
// with no source and no data loss. Refusing here would discard the
|
|
752
790
|
// `1` and the `2` on a path that never leaked.
|
|
753
|
-
|
|
791
|
+
//
|
|
792
|
+
// A value `JSON.stringify` cannot serialize AT ALL is the other half
|
|
793
|
+
// (#1253): a cycle, a `BigInt`, or an author `toJSON()` that throws.
|
|
794
|
+
// The throw used to escape reflection entirely, so a property
|
|
795
|
+
// assignment threw from the setter, a client upgrade threw out of
|
|
796
|
+
// `connectedCallback` before the first render, and an SSR render was
|
|
797
|
+
// swallowed by per-component isolation, which surfaces an error box in
|
|
798
|
+
// dev and emits the component EMPTY at a 200 in PRODUCTION, where the
|
|
799
|
+
// cause is only in the server log. There is no string to put in the
|
|
800
|
+
// attribute, so the
|
|
801
|
+
// attribute goes, exactly as it does for a function. The `catch` IS
|
|
802
|
+
// the detection: `JSON.stringify` already walks the value, so its own
|
|
803
|
+
// failure covers all three causes, where a cycle-only pre-walk would
|
|
804
|
+
// miss the other two and pay a full traversal per reflection.
|
|
805
|
+
let serialized;
|
|
806
|
+
let serializable = true;
|
|
807
|
+
try {
|
|
808
|
+
serialized = JSON.stringify(value);
|
|
809
|
+
} catch (e) {
|
|
810
|
+
serializable = false;
|
|
811
|
+
this.removeAttribute(attrName);
|
|
812
|
+
warnUnserializableReflection(this, propName, attrName, e && e.message);
|
|
813
|
+
}
|
|
814
|
+
// Outside the `try` on purpose, so a genuine `setAttribute` failure
|
|
815
|
+
// (an invalid attribute name) still surfaces instead of being folded
|
|
816
|
+
// into the unserializable path.
|
|
817
|
+
if (serializable) this.setAttribute(attrName, serialized);
|
|
754
818
|
} else if (carriesFunction(value)) {
|
|
755
819
|
// The string fall-through is the one place a CARRIED function still
|
|
756
820
|
// leaks. `String([fn])` is `Array.prototype.join`, which runs
|
|
@@ -1103,7 +1167,35 @@ class WebComponentBase extends Base {
|
|
|
1103
1167
|
} else if (def.type === Boolean) {
|
|
1104
1168
|
v = value != null && value !== 'false';
|
|
1105
1169
|
} else if (def.type === Object || def.type === Array) {
|
|
1106
|
-
|
|
1170
|
+
// An attribute that is not parseable JSON yields `null` rather than the
|
|
1171
|
+
// raw string (#1253), because a STRING is never a valid value for a
|
|
1172
|
+
// property the author declared `Object` or `Array`, whatever put it
|
|
1173
|
+
// there. lit's `defaultConverter.fromAttribute` lands on the same `null`,
|
|
1174
|
+
// for the reason its own comment gives: an element does not complain
|
|
1175
|
+
// about being mis-configured.
|
|
1176
|
+
//
|
|
1177
|
+
// `applyAttrsToInstance` in `render-server.js` is the SSR counterpart of
|
|
1178
|
+
// THIS branch and yields `null` for the same input. They have to agree,
|
|
1179
|
+
// or an element SSRs holding one value and re-renders holding another.
|
|
1180
|
+
// The agreement is about an attribute that is PRESENT and unparseable;
|
|
1181
|
+
// an attribute that was never there does not reach either reader, so
|
|
1182
|
+
// such a prop simply keeps its constructor value.
|
|
1183
|
+
//
|
|
1184
|
+
// That agreement is about this FALLBACK, not a guarantee that the two
|
|
1185
|
+
// readers see the same attributes in the first place. They reach an
|
|
1186
|
+
// attribute by different routes (this one via `observedAttributes` and
|
|
1187
|
+
// the browser's own name-lowercasing, the SSR one by walking the parsed
|
|
1188
|
+
// source tag), and hand-written markup can land in the gaps between
|
|
1189
|
+
// those routes, so an author literal can still be read by one and not
|
|
1190
|
+
// the other. Those gaps predate #1253 and it neither causes nor closes
|
|
1191
|
+
// any of them; no attempt is made here to enumerate them, because every
|
|
1192
|
+
// attempt so far has been incomplete.
|
|
1193
|
+
//
|
|
1194
|
+
// Scoped to the default converter. A prop supplying its own
|
|
1195
|
+
// `converter.fromAttribute` is handled by the arm above and the SSR
|
|
1196
|
+
// reader has no converter arm at all, so the two sides already read such
|
|
1197
|
+
// a prop differently. That gap predates #1253 and is left alone.
|
|
1198
|
+
try { v = value == null ? null : JSON.parse(value); } catch { v = null; }
|
|
1107
1199
|
} else {
|
|
1108
1200
|
v = value;
|
|
1109
1201
|
}
|
package/src/render-server.js
CHANGED
|
@@ -400,12 +400,15 @@ async function renderTemplate(tr, ctx) {
|
|
|
400
400
|
// page-level `.prop` on a native element could have set the
|
|
401
401
|
// property to begin with.
|
|
402
402
|
out = out.slice(0, attrStart);
|
|
403
|
-
// `<webjs-suspense .fallback=${html`...`}>` (#471).
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
403
|
+
// `<webjs-suspense .fallback=${html`...`}>` (#471). This element is
|
|
404
|
+
// defined only in the browser, so the injectDSD walk skips it
|
|
405
|
+
// (`lookup(tag)` finds no class) and no server-side instance runs
|
|
406
|
+
// consumePropAttrs. A normal data-webjs-prop-* binding would then
|
|
407
|
+
// land at connectedCallback, too late for the streaming placeholder.
|
|
408
|
+
// So render the fallback to HTML now and carry it as
|
|
409
|
+
// data-webjs-fallback, which the injectDSD streaming pre-pass reads
|
|
410
|
+
// as the boundary placeholder. (The value itself would serialize
|
|
411
|
+
// fine: a TemplateResult is a plain {strings, values} object.)
|
|
409
412
|
if (currentTag === 'webjs-suspense' && name === 'fallback') {
|
|
410
413
|
const fbHtml = await render(val, ctx);
|
|
411
414
|
out += `data-webjs-fallback="${escapeAttr(fbHtml)}"`;
|
|
@@ -1727,9 +1730,37 @@ function applyAttrsToInstance(instance, attrs, Cls) {
|
|
|
1727
1730
|
// `raw` is the entity-encoded attribute text (parseAttrs returns the
|
|
1728
1731
|
// literal characters between the quotes), so decode the HTML entities
|
|
1729
1732
|
// before JSON.parse. A JSON attribute carries `"` for every `"`;
|
|
1730
|
-
// parsing it raw throws and
|
|
1731
|
-
//
|
|
1732
|
-
|
|
1733
|
+
// parsing it raw throws, and the prop then falls to the failure value
|
|
1734
|
+
// below rather than to the object the author wrote.
|
|
1735
|
+
//
|
|
1736
|
+
// An unparseable attribute falls back to `null`, NOT to the raw string
|
|
1737
|
+
// (#1253), matching this branch's counterpart in
|
|
1738
|
+
// `attributeChangedCallback`. They have to agree, or the same
|
|
1739
|
+
// `<my-el cfg="oops">` SSRs holding a string and re-renders holding
|
|
1740
|
+
// something else the moment the element upgrades. `null` is the right
|
|
1741
|
+
// value to agree on: a string is never a valid value for a property the
|
|
1742
|
+
// author declared `Object` or `Array`. This function iterates the
|
|
1743
|
+
// attributes PRESENT on the source tag, so an absent one is not read here
|
|
1744
|
+
// at all and its property keeps whatever the constructor gave it; the
|
|
1745
|
+
// agreement being asserted is about a present, unparseable attribute.
|
|
1746
|
+
//
|
|
1747
|
+
// That agreement is about this FALLBACK, not a guarantee that the two
|
|
1748
|
+
// readers see the same attributes in the first place. This function
|
|
1749
|
+
// walks the parsed source tag while the client goes through
|
|
1750
|
+
// `observedAttributes` and the browser's own name-lowercasing, and
|
|
1751
|
+
// hand-written markup can land in the gaps between those routes (the
|
|
1752
|
+
// entity decoding just below is one, since it reverses far less than a
|
|
1753
|
+
// browser does). Those gaps predate #1253 and it neither causes nor
|
|
1754
|
+
// closes any of them; no attempt is made here to enumerate them,
|
|
1755
|
+
// because every attempt so far has been incomplete.
|
|
1756
|
+
//
|
|
1757
|
+
// Scoped to the DEFAULT converter, which is all this branch is. The
|
|
1758
|
+
// client reader tries `converter.fromAttribute` FIRST and only falls
|
|
1759
|
+
// through to here when there is none, while this function has no
|
|
1760
|
+
// converter arm at all, so a prop declaring one is read differently by
|
|
1761
|
+
// the two sides regardless of what this line does. That gap predates
|
|
1762
|
+
// #1253 and is left alone rather than widened.
|
|
1763
|
+
try { instance[propName] = JSON.parse(unescapeAttr(raw)); } catch { instance[propName] = null; }
|
|
1733
1764
|
} else instance[propName] = raw;
|
|
1734
1765
|
}
|
|
1735
1766
|
}
|
package/src/router-client.js
CHANGED
|
@@ -859,9 +859,13 @@ function onSubmit(e) {
|
|
|
859
859
|
const submitter = /** @type {HTMLElement | null} */ (e.submitter ?? null);
|
|
860
860
|
if (submitter && submitter.hasAttribute('data-no-router')) return;
|
|
861
861
|
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
862
|
+
// Presence, not truthiness, exactly as `getSubmitAction` resolves the action
|
|
863
|
+
// (#1322). The form-submission algorithm asks whether the submitter HAS a
|
|
864
|
+
// `formtarget`, so a present-but-empty one overrides the form and then means
|
|
865
|
+
// the current browsing context, per the rules for choosing a navigable.
|
|
866
|
+
const target = (submitter && submitter.hasAttribute('formtarget'))
|
|
867
|
+
? (submitter.getAttribute('formtarget') || '')
|
|
868
|
+
: (form.getAttribute('target') || '');
|
|
865
869
|
if (target && target !== '_self') return;
|
|
866
870
|
|
|
867
871
|
const method = getSubmitMethod(form, submitter);
|
|
@@ -911,14 +915,21 @@ function onSubmit(e) {
|
|
|
911
915
|
* Method resolution: submitter's `formmethod` wins over form's `method`.
|
|
912
916
|
* Returns lowercase.
|
|
913
917
|
*
|
|
918
|
+
* Resolved on PRESENCE, not truthiness (#1322), the same way `getSubmitAction`
|
|
919
|
+
* below resolves the action. The form-submission algorithm reads the
|
|
920
|
+
* submitter's `formmethod` if the submitter HAS one, so a present-but-empty
|
|
921
|
+
* `formmethod=""` overrides the form and then falls to its own invalid-value
|
|
922
|
+
* default, GET. Resolving with `||` instead sent a multipart POST for a button
|
|
923
|
+
* every engine submits as `GET`, which is a JS-on versus JS-off divergence.
|
|
924
|
+
*
|
|
914
925
|
* @param {HTMLFormElement} form
|
|
915
926
|
* @param {HTMLElement | null} submitter
|
|
916
927
|
*/
|
|
917
928
|
function getSubmitMethod(form, submitter) {
|
|
918
|
-
const v = (submitter && submitter.
|
|
919
|
-
|
|
920
|
-
|| '
|
|
921
|
-
return v.toLowerCase();
|
|
929
|
+
const v = (submitter && submitter.hasAttribute('formmethod'))
|
|
930
|
+
? (submitter.getAttribute('formmethod') || '')
|
|
931
|
+
: (form.getAttribute('method') || '');
|
|
932
|
+
return (v || 'get').toLowerCase();
|
|
922
933
|
}
|
|
923
934
|
|
|
924
935
|
/**
|
|
@@ -965,12 +976,19 @@ function normalizeEnctype(raw) {
|
|
|
965
976
|
* `enctype`, exactly as `getSubmitMethod` resolves the method (#1307). Turbo
|
|
966
977
|
* resolves it the same way, in `core/drive/form_submission.js`.
|
|
967
978
|
*
|
|
979
|
+
* On PRESENCE, not truthiness (#1322), for the reason spelled out on
|
|
980
|
+
* `getSubmitMethod`: a present-but-empty `formenctype=""` overrides the form
|
|
981
|
+
* and normalizes to urlencoded, its own invalid-value default, so a button on
|
|
982
|
+
* a multipart form sends urlencoded with JS on exactly as it does with JS off.
|
|
983
|
+
*
|
|
968
984
|
* @param {HTMLFormElement} form
|
|
969
985
|
* @param {HTMLElement | null} submitter
|
|
970
986
|
*/
|
|
971
987
|
function getSubmitEnctype(form, submitter) {
|
|
972
988
|
return normalizeEnctype(
|
|
973
|
-
(submitter && submitter.
|
|
989
|
+
(submitter && submitter.hasAttribute('formenctype'))
|
|
990
|
+
? submitter.getAttribute('formenctype')
|
|
991
|
+
: form.getAttribute('enctype'),
|
|
974
992
|
);
|
|
975
993
|
}
|
|
976
994
|
|
|
@@ -1537,7 +1555,7 @@ function setNavigating(on) {
|
|
|
1537
1555
|
* @returns {Map<string, { routeKey: string, start: Comment, end: Comment }> | null}
|
|
1538
1556
|
* The boundary map, or null when the tree's boundaries are malformed.
|
|
1539
1557
|
*/
|
|
1540
|
-
|
|
1558
|
+
function collectBoundaries(root) {
|
|
1541
1559
|
/** @type {Map<string, { routeKey: string, start: Comment, end: Comment }>} */
|
|
1542
1560
|
const out = new Map();
|
|
1543
1561
|
/** @type {{ segment: string, routeKey: string, start: Comment }[]} */
|
|
@@ -1654,7 +1672,7 @@ export function collectBoundaries(root) {
|
|
|
1654
1672
|
* live: { routeKey: string, start: Comment, end: Comment },
|
|
1655
1673
|
* incoming: { routeKey: string, start: Comment, end: Comment } } | null}
|
|
1656
1674
|
*/
|
|
1657
|
-
|
|
1675
|
+
function planBoundarySwap(here, there) {
|
|
1658
1676
|
// Shared segments, shallowest first (a nested path prefix is shorter).
|
|
1659
1677
|
const shared = [...here.keys()].filter((s) => there.has(s)).sort((a, b) => a.length - b.length);
|
|
1660
1678
|
if (shared.length === 0) return null;
|
package/src/websocket-client.js
CHANGED
|
@@ -81,7 +81,8 @@ export function connectWS(url, opts = {}) {
|
|
|
81
81
|
|
|
82
82
|
return {
|
|
83
83
|
/** Send a message. Objects are JSON-stringified; strings and binary pass through.
|
|
84
|
-
* If the socket isn't open yet, the message is queued until it is.
|
|
84
|
+
* If the socket isn't open yet, the message is queued until it is.
|
|
85
|
+
* @param {string | ArrayBuffer | ArrayBufferView | object} data */
|
|
85
86
|
send(data) {
|
|
86
87
|
const payload =
|
|
87
88
|
typeof data === 'string' || data instanceof ArrayBuffer || ArrayBuffer.isView(data)
|
|
@@ -90,7 +91,9 @@ export function connectWS(url, opts = {}) {
|
|
|
90
91
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(/** @type any */ (payload));
|
|
91
92
|
else queue.push(payload);
|
|
92
93
|
},
|
|
93
|
-
/** Permanently close the socket. Disables reconnect.
|
|
94
|
+
/** Permanently close the socket. Disables reconnect.
|
|
95
|
+
* @param {number} [code]
|
|
96
|
+
* @param {string} [reason] */
|
|
94
97
|
close(code, reason) {
|
|
95
98
|
stopped = true;
|
|
96
99
|
try { ws?.close(code, reason); } catch {}
|