@jsenv/core 41.5.20 → 41.5.22
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/dist/build/build.js +152 -0
- package/dist/client/server_events/server_events_client.js +57 -41
- package/dist/start_dev_server/start_dev_server.js +315 -35
- package/package.json +2 -2
- package/src/build/build.js +14 -0
- package/src/dev/start_dev_server.js +25 -2
- package/src/helpers/watch_dependencies.js +156 -34
- package/src/plugins/patches/jsenv_plugin_patches.js +137 -0
- package/src/plugins/plugins.js +5 -0
- package/src/plugins/server_events/client/server_events_client.js +57 -41
|
@@ -1,26 +1,47 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Detects
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Detects the moment "npm install" changes what the running pages get from
|
|
3
|
+
* node_modules, so the browser can be reloaded at that moment:
|
|
4
|
+
*
|
|
5
|
+
* - a declared dependency stops being missing or outdated: what package.json
|
|
6
|
+
* asks for is there;
|
|
7
|
+
* - a package the pages were served changes version: the "?v=" baked into the
|
|
8
|
+
* urls they evaluated names a version node_modules no longer holds. Left
|
|
9
|
+
* alone, the next hot reload of a file importing that package resolves it to
|
|
10
|
+
* the new version and the page evaluates a second copy of the package next
|
|
11
|
+
* to the first, two module scopes for something meant to exist once.
|
|
5
12
|
*
|
|
6
13
|
* node_modules is deliberately not watched: it is far too big, and an install
|
|
7
14
|
* rewrites, dedupes and moves package directories around, so a watcher placed
|
|
8
|
-
* on one of them is unreliable. Instead
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
15
|
+
* on one of them is unreliable. Instead a few package.json files are polled,
|
|
16
|
+
* at the cost of a stat each: the packages known to be missing or outdated,
|
|
17
|
+
* and the packages the pages were served, whose package.json the url graph
|
|
18
|
+
* keeps (that is where "?v=" comes from). The project package.json is watched
|
|
19
|
+
* though: it is a single file, and editing it is what puts a dependency out of
|
|
20
|
+
* date in the first place.
|
|
21
|
+
*
|
|
22
|
+
* Both detections run in the same tick so that an install fixing an outdated
|
|
23
|
+
* package the page runs is reported once: two reports would be two reloads,
|
|
24
|
+
* the second one landing on the page that just came back.
|
|
13
25
|
*/
|
|
14
26
|
|
|
15
27
|
import { registerFileLifecycle } from "@jsenv/filesystem";
|
|
16
28
|
|
|
17
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
packageNameFromSpecifier,
|
|
31
|
+
readDependencyStatuses,
|
|
32
|
+
} from "../kitchen/package_dependencies.js";
|
|
18
33
|
|
|
19
34
|
const POLL_INTERVAL = 500;
|
|
20
35
|
|
|
21
36
|
export const watchDependencies = (
|
|
22
37
|
packageDirectory,
|
|
23
|
-
{
|
|
38
|
+
{
|
|
39
|
+
getKitchens = () => [],
|
|
40
|
+
onProblem,
|
|
41
|
+
onInstalled,
|
|
42
|
+
onChange,
|
|
43
|
+
pollInterval = POLL_INTERVAL,
|
|
44
|
+
},
|
|
24
45
|
) => {
|
|
25
46
|
let problemMap = new Map();
|
|
26
47
|
// every path given to the browser is relative to the package directory, the
|
|
@@ -35,11 +56,49 @@ export const watchDependencies = (
|
|
|
35
56
|
if (!packageDirectory.url) {
|
|
36
57
|
return watcher;
|
|
37
58
|
}
|
|
38
|
-
let timer = null;
|
|
39
59
|
|
|
40
|
-
const
|
|
60
|
+
const checkServedVersions = (installMap) => {
|
|
61
|
+
for (const kitchen of getKitchens()) {
|
|
62
|
+
for (const urlInfo of kitchen.graph.urlInfoMap.values()) {
|
|
63
|
+
const served = readServedVersion(urlInfo);
|
|
64
|
+
if (!served) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const installedPackageJson = readInstalledPackageJson(
|
|
68
|
+
packageDirectory,
|
|
69
|
+
served.directoryUrl,
|
|
70
|
+
);
|
|
71
|
+
if (!installedPackageJson) {
|
|
72
|
+
// the package is being rewritten, what it becomes is known once it is back
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const installedVersion = installedPackageJson.version;
|
|
76
|
+
if (installedVersion === served.version) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// the graph learns the file moved, as it would from a watcher: the
|
|
80
|
+
// files importing this package are cooked again with the new "?v="
|
|
81
|
+
// when the page comes back
|
|
82
|
+
urlInfo.onModified();
|
|
83
|
+
if (installMap.has(served.packageName)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
installMap.set(served.packageName, {
|
|
87
|
+
packageName: served.packageName,
|
|
88
|
+
installedVersion,
|
|
89
|
+
servedVersion: served.version,
|
|
90
|
+
declaredVersion: null,
|
|
91
|
+
severity: "warning",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const checkDeclared = (installMap) => {
|
|
98
|
+
const statusMap = new Map();
|
|
41
99
|
const nextProblemMap = new Map();
|
|
42
100
|
for (const status of readDependencyStatuses(packageDirectory)) {
|
|
101
|
+
statusMap.set(status.packageName, status);
|
|
43
102
|
if (status.state === "missing" || status.state === "outdated") {
|
|
44
103
|
nextProblemMap.set(status.packageName, status);
|
|
45
104
|
}
|
|
@@ -55,9 +114,21 @@ export const watchDependencies = (
|
|
|
55
114
|
}
|
|
56
115
|
}
|
|
57
116
|
for (const [packageName, previousStatus] of problemMap) {
|
|
58
|
-
if (
|
|
59
|
-
|
|
117
|
+
if (nextProblemMap.has(packageName)) {
|
|
118
|
+
continue;
|
|
60
119
|
}
|
|
120
|
+
const install = installMap.get(packageName);
|
|
121
|
+
if (install) {
|
|
122
|
+
install.declaredVersion = previousStatus.declaredVersion;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
installMap.set(packageName, {
|
|
126
|
+
packageName,
|
|
127
|
+
installedVersion: statusMap.get(packageName).installedVersion,
|
|
128
|
+
servedVersion: null,
|
|
129
|
+
declaredVersion: previousStatus.declaredVersion,
|
|
130
|
+
severity: previousStatus.severity,
|
|
131
|
+
});
|
|
61
132
|
}
|
|
62
133
|
const changed =
|
|
63
134
|
nextProblemMap.size !== problemMap.size ||
|
|
@@ -75,41 +146,92 @@ export const watchDependencies = (
|
|
|
75
146
|
if (changed) {
|
|
76
147
|
onChange(watcher.getProblems());
|
|
77
148
|
}
|
|
78
|
-
if (problemMap.size === 0) {
|
|
79
|
-
stopPolling();
|
|
80
|
-
} else {
|
|
81
|
-
startPolling();
|
|
82
|
-
}
|
|
83
149
|
};
|
|
84
150
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
151
|
+
// The declared dependencies are compared with node_modules only when
|
|
152
|
+
// something can have changed for them: the project package.json was edited,
|
|
153
|
+
// or a problem is known and an install may be fixing it. Comparing them at
|
|
154
|
+
// every tick would catch installs halfway and report a package as missing
|
|
155
|
+
// while it is being rewritten.
|
|
156
|
+
const check = ({ declared }) => {
|
|
157
|
+
const installMap = new Map();
|
|
158
|
+
checkServedVersions(installMap);
|
|
159
|
+
if (declared || problemMap.size > 0) {
|
|
160
|
+
checkDeclared(installMap);
|
|
88
161
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
};
|
|
92
|
-
const stopPolling = () => {
|
|
93
|
-
if (!timer) {
|
|
94
|
-
return;
|
|
162
|
+
for (const install of installMap.values()) {
|
|
163
|
+
onInstalled(install);
|
|
95
164
|
}
|
|
96
|
-
clearInterval(timer);
|
|
97
|
-
timer = null;
|
|
98
165
|
};
|
|
99
166
|
|
|
100
167
|
const unwatchPackageJson = registerFileLifecycle(
|
|
101
168
|
new URL("package.json", packageDirectory.url),
|
|
102
169
|
{
|
|
103
|
-
added: check,
|
|
104
|
-
updated: check,
|
|
170
|
+
added: () => check({ declared: true }),
|
|
171
|
+
updated: () => check({ declared: true }),
|
|
105
172
|
keepProcessAlive: false,
|
|
106
173
|
},
|
|
107
174
|
);
|
|
108
|
-
check();
|
|
175
|
+
check({ declared: true });
|
|
176
|
+
const timer = setInterval(() => {
|
|
177
|
+
check({ declared: false });
|
|
178
|
+
}, pollInterval);
|
|
179
|
+
timer.unref();
|
|
109
180
|
|
|
110
181
|
watcher.stop = () => {
|
|
111
|
-
|
|
182
|
+
clearInterval(timer);
|
|
112
183
|
unwatchPackageJson();
|
|
113
184
|
};
|
|
114
185
|
return watcher;
|
|
115
186
|
};
|
|
187
|
+
|
|
188
|
+
// The url graph keeps the content of every package.json it resolved a bare
|
|
189
|
+
// specifier against, and that content holds the version the page received.
|
|
190
|
+
// It is parsed once per content: the same string is seen at every tick.
|
|
191
|
+
const servedVersionCache = new WeakMap();
|
|
192
|
+
const readServedVersion = (urlInfo) => {
|
|
193
|
+
const { url, content } = urlInfo;
|
|
194
|
+
if (content === undefined) {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
if (!url.startsWith("file:") || !url.endsWith("/package.json")) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const nodeModulesIndex = url.lastIndexOf("/node_modules/");
|
|
201
|
+
if (nodeModulesIndex === -1) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
// dereferenced url infos stay in the graph; nothing imports this package anymore
|
|
205
|
+
if (urlInfo.referenceFromOthersSet.size === 0) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const fromCache = servedVersionCache.get(urlInfo);
|
|
209
|
+
if (fromCache && fromCache.content === content) {
|
|
210
|
+
return fromCache;
|
|
211
|
+
}
|
|
212
|
+
let packageJson;
|
|
213
|
+
try {
|
|
214
|
+
packageJson = JSON.parse(content);
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const served = {
|
|
219
|
+
content,
|
|
220
|
+
directoryUrl: new URL("./", url).href,
|
|
221
|
+
packageName: packageNameFromSpecifier(
|
|
222
|
+
url.slice(nodeModulesIndex + "/node_modules/".length),
|
|
223
|
+
),
|
|
224
|
+
version: packageJson.version,
|
|
225
|
+
};
|
|
226
|
+
servedVersionCache.set(urlInfo, served);
|
|
227
|
+
return served;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// an install in progress can be caught halfway, with a package.json not written yet
|
|
231
|
+
const readInstalledPackageJson = (packageDirectory, directoryUrl) => {
|
|
232
|
+
try {
|
|
233
|
+
return packageDirectory.read(directoryUrl);
|
|
234
|
+
} catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Text patches applied to files as they are served and built, keyed by file:
|
|
3
|
+
*
|
|
4
|
+
* patches: {
|
|
5
|
+
* "preact/dist/preact.mjs": [{ from: "a&&b", to: "a&&b&&c" }],
|
|
6
|
+
* }
|
|
7
|
+
*
|
|
8
|
+
* A key is a url pattern relative to the root directory ("./main.js",
|
|
9
|
+
* "**\/*.css"), or a path inside a package ("preact/dist/preact.mjs") found by
|
|
10
|
+
* walking up from the root directory into node_modules, the way node does,
|
|
11
|
+
* so the key holds wherever the package manager hoists the package.
|
|
12
|
+
*
|
|
13
|
+
* Every `from` must occur exactly once in the file, otherwise the file fails
|
|
14
|
+
* to cook and says which patch did not apply: a dependency update that moved
|
|
15
|
+
* the patched code must be looked at, never silently unpatched.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createMagicSource } from "@jsenv/sourcemap";
|
|
19
|
+
import { URL_META } from "@jsenv/url-meta";
|
|
20
|
+
import { asUrlWithoutSearch, urlToRelativeUrl } from "@jsenv/urls";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
|
|
23
|
+
export const jsenvPluginPatches = (rawPatches) => {
|
|
24
|
+
if (!rawPatches || Object.keys(rawPatches).length === 0) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
let findPatches;
|
|
28
|
+
const patchesPlugin = {
|
|
29
|
+
name: "jsenv:patches",
|
|
30
|
+
appliesDuring: "*",
|
|
31
|
+
init: (context) => {
|
|
32
|
+
const { rootDirectoryUrl } = context;
|
|
33
|
+
const patchesByPattern = {};
|
|
34
|
+
for (const key of Object.keys(rawPatches)) {
|
|
35
|
+
const patches = rawPatches[key];
|
|
36
|
+
assertPatches(patches, key);
|
|
37
|
+
patchesByPattern[resolvePatchKey(key, rootDirectoryUrl)] = patches;
|
|
38
|
+
}
|
|
39
|
+
const associations = URL_META.resolveAssociations(
|
|
40
|
+
{ patches: patchesByPattern },
|
|
41
|
+
rootDirectoryUrl,
|
|
42
|
+
);
|
|
43
|
+
findPatches = (url) => {
|
|
44
|
+
const { patches } = URL_META.applyAssociations({
|
|
45
|
+
url: asUrlWithoutSearch(url),
|
|
46
|
+
associations,
|
|
47
|
+
});
|
|
48
|
+
return patches;
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
transformUrlContent: (urlInfo) => {
|
|
52
|
+
const patches = findPatches(urlInfo.url);
|
|
53
|
+
if (!patches) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const { content } = urlInfo;
|
|
57
|
+
const magicSource = createMagicSource(content);
|
|
58
|
+
for (const { from, to } of patches) {
|
|
59
|
+
const start = content.indexOf(from);
|
|
60
|
+
const occurrenceCount =
|
|
61
|
+
start === -1 ? 0 : content.indexOf(from, start + 1) === -1 ? 1 : 2;
|
|
62
|
+
if (occurrenceCount !== 1) {
|
|
63
|
+
const fileRelativeUrl = urlToRelativeUrl(
|
|
64
|
+
urlInfo.url,
|
|
65
|
+
urlInfo.context.rootDirectoryUrl,
|
|
66
|
+
);
|
|
67
|
+
throw new Error(
|
|
68
|
+
`patch cannot apply on "${fileRelativeUrl}": ${JSON.stringify(from)} found ${occurrenceCount === 0 ? "nowhere" : "more than once"} in the file. The file may have changed since the patch was written.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
magicSource.replace({
|
|
72
|
+
start,
|
|
73
|
+
end: start + from.length,
|
|
74
|
+
replacement: to,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return magicSource.toContentAndSourcemap();
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
return [patchesPlugin];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const assertPatches = (patches, key) => {
|
|
84
|
+
if (!Array.isArray(patches)) {
|
|
85
|
+
throw new TypeError(
|
|
86
|
+
`patches["${key}"] must be an array of { from, to }, got ${patches}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
for (const patch of patches) {
|
|
90
|
+
if (
|
|
91
|
+
!patch ||
|
|
92
|
+
typeof patch.from !== "string" ||
|
|
93
|
+
patch.from === "" ||
|
|
94
|
+
typeof patch.to !== "string"
|
|
95
|
+
) {
|
|
96
|
+
throw new TypeError(
|
|
97
|
+
`patches["${key}"] entries must be { from: string, to: string } with a non-empty "from"`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// "./x", "../x", "/x", "file:///x" and "**/x" are url patterns; anything
|
|
104
|
+
// else names a path inside a package, looked up in node_modules
|
|
105
|
+
const resolvePatchKey = (key, rootDirectoryUrl) => {
|
|
106
|
+
if (
|
|
107
|
+
key.startsWith("./") ||
|
|
108
|
+
key.startsWith("../") ||
|
|
109
|
+
key.startsWith("/") ||
|
|
110
|
+
key.startsWith("file:") ||
|
|
111
|
+
key.startsWith("*")
|
|
112
|
+
) {
|
|
113
|
+
return key;
|
|
114
|
+
}
|
|
115
|
+
const segments = key.split("/");
|
|
116
|
+
const packageName = key.startsWith("@")
|
|
117
|
+
? `${segments[0]}/${segments[1]}`
|
|
118
|
+
: segments[0];
|
|
119
|
+
const pathInsidePackage = key.slice(packageName.length);
|
|
120
|
+
let directoryUrl = new URL(rootDirectoryUrl);
|
|
121
|
+
while (true) {
|
|
122
|
+
const packageDirectoryUrl = new URL(
|
|
123
|
+
`./node_modules/${packageName}/`,
|
|
124
|
+
directoryUrl,
|
|
125
|
+
);
|
|
126
|
+
if (existsSync(packageDirectoryUrl)) {
|
|
127
|
+
return String(new URL(`.${pathInsidePackage}`, packageDirectoryUrl));
|
|
128
|
+
}
|
|
129
|
+
const parentDirectoryUrl = new URL("../", directoryUrl);
|
|
130
|
+
if (parentDirectoryUrl.href === directoryUrl.href) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`patches["${key}"]: package "${packageName}" not found in any node_modules above ${rootDirectoryUrl}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
directoryUrl = parentDirectoryUrl;
|
|
136
|
+
}
|
|
137
|
+
};
|
package/src/plugins/plugins.js
CHANGED
|
@@ -11,6 +11,7 @@ import { jsenvPluginProtocolFile } from "./protocol_file/jsenv_plugin_protocol_f
|
|
|
11
11
|
import { jsenvPluginProtocolHttp } from "./protocol_http/jsenv_plugin_protocol_http.js";
|
|
12
12
|
import { jsenvPluginDirectoryReferenceEffect } from "./directory_reference_effect/jsenv_plugin_directory_reference_effect.js";
|
|
13
13
|
import { jsenvPluginInjections } from "./injections/jsenv_plugin_injections.js";
|
|
14
|
+
import { jsenvPluginPatches } from "./patches/jsenv_plugin_patches.js";
|
|
14
15
|
import { jsenvPluginInlining } from "./inlining/jsenv_plugin_inlining.js";
|
|
15
16
|
import { jsenvPluginCommonJsGlobals } from "./commonjs_globals/jsenv_plugin_commonjs_globals.js";
|
|
16
17
|
import { jsenvPluginImportMetaScenarios } from "./import_meta_scenarios/jsenv_plugin_import_meta_scenarios.js";
|
|
@@ -51,6 +52,7 @@ export const getCorePlugins = ({
|
|
|
51
52
|
directoryListing = true,
|
|
52
53
|
directoryReferenceEffect,
|
|
53
54
|
supervisor,
|
|
55
|
+
patches,
|
|
54
56
|
injections,
|
|
55
57
|
transpilation = true,
|
|
56
58
|
inlining = true,
|
|
@@ -92,6 +94,9 @@ export const getCorePlugins = ({
|
|
|
92
94
|
...(packageBundle
|
|
93
95
|
? [jsenvPluginWorkspaceBundle({ packageDirectory })]
|
|
94
96
|
: []),
|
|
97
|
+
// before everything else: what the other plugins read must be the
|
|
98
|
+
// patched file
|
|
99
|
+
...jsenvPluginPatches(patches),
|
|
95
100
|
// before reference analysis: an url written by an injection must hold its
|
|
96
101
|
// final value when references are analyzed
|
|
97
102
|
jsenvPluginInjections(injections),
|
|
@@ -4,7 +4,7 @@ events_manager: {
|
|
|
4
4
|
const callbacksMap = new Map();
|
|
5
5
|
let cleanup;
|
|
6
6
|
const addCallbacks = (namedCallbacks) => {
|
|
7
|
-
|
|
7
|
+
const sizeBeforeAdd = callbacksMap.size;
|
|
8
8
|
Object.keys(namedCallbacks).forEach((eventName) => {
|
|
9
9
|
const callback = namedCallbacks[eventName];
|
|
10
10
|
const existingCallbacks = callbacksMap.get(eventName);
|
|
@@ -17,7 +17,10 @@ events_manager: {
|
|
|
17
17
|
}
|
|
18
18
|
callbacks.push(callback);
|
|
19
19
|
});
|
|
20
|
-
|
|
20
|
+
// the effect owns whatever the callbacks need to exist (the connection),
|
|
21
|
+
// so it runs on the edge into "someone is listening" and its cleanup runs
|
|
22
|
+
// on the edge back out of it
|
|
23
|
+
if (sizeBeforeAdd === 0 && callbacksMap.size > 0) {
|
|
21
24
|
cleanup = effect();
|
|
22
25
|
}
|
|
23
26
|
|
|
@@ -25,7 +28,7 @@ events_manager: {
|
|
|
25
28
|
return () => {
|
|
26
29
|
if (removed) return;
|
|
27
30
|
removed = true;
|
|
28
|
-
|
|
31
|
+
const sizeBeforeRemove = callbacksMap.size;
|
|
29
32
|
Object.keys(namedCallbacks).forEach((eventName) => {
|
|
30
33
|
const callback = namedCallbacks[eventName];
|
|
31
34
|
const callbacks = callbacksMap.get(eventName);
|
|
@@ -40,12 +43,7 @@ events_manager: {
|
|
|
40
43
|
}
|
|
41
44
|
});
|
|
42
45
|
namedCallbacks = null; // allow garbage collect
|
|
43
|
-
if (
|
|
44
|
-
cleanup &&
|
|
45
|
-
typeof cleanup === "function" &&
|
|
46
|
-
callbacksMapSize > 0 &&
|
|
47
|
-
callbacksMapSize.size === 0
|
|
48
|
-
) {
|
|
46
|
+
if (cleanup && sizeBeforeRemove > 0 && callbacksMap.size === 0) {
|
|
49
47
|
cleanup();
|
|
50
48
|
cleanup = null;
|
|
51
49
|
}
|
|
@@ -86,9 +84,14 @@ connection_manager: {
|
|
|
86
84
|
CLOSED: "closed",
|
|
87
85
|
};
|
|
88
86
|
|
|
87
|
+
// The peer is a dev server being restarted, not a flaky network: there is no
|
|
88
|
+
// attempt count or time budget past which giving up would be correct. Attempts
|
|
89
|
+
// space out to retryAfterMax and never stop, and the moments where the server
|
|
90
|
+
// is most likely to be back already (tab visible, window focused, network
|
|
91
|
+
// back) skip the wait instead of leaving a detached page on screen.
|
|
89
92
|
createConnectionManager = (
|
|
90
93
|
attemptConnection,
|
|
91
|
-
{ logs, retry, retryAfter,
|
|
94
|
+
{ logs, retry, retryAfter, retryAfterMax },
|
|
92
95
|
) => {
|
|
93
96
|
const readyState = {
|
|
94
97
|
value: READY_STATES.CLOSED,
|
|
@@ -103,6 +106,7 @@ connection_manager: {
|
|
|
103
106
|
};
|
|
104
107
|
|
|
105
108
|
let _disconnect = () => {};
|
|
109
|
+
let _retryNow = () => {};
|
|
106
110
|
const connect = () => {
|
|
107
111
|
if (
|
|
108
112
|
readyState.value === READY_STATES.CONNECTING ||
|
|
@@ -111,11 +115,11 @@ connection_manager: {
|
|
|
111
115
|
return;
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
let
|
|
115
|
-
let
|
|
118
|
+
let retryDelay = retryAfter;
|
|
119
|
+
let retryTimeout = null;
|
|
116
120
|
const attempt = () => {
|
|
121
|
+
retryTimeout = null;
|
|
117
122
|
readyState.goTo(READY_STATES.CONNECTING);
|
|
118
|
-
let timeout;
|
|
119
123
|
const cancelAttempt = attemptConnection({
|
|
120
124
|
onClosed: () => {
|
|
121
125
|
if (!retry) {
|
|
@@ -125,24 +129,6 @@ connection_manager: {
|
|
|
125
129
|
}
|
|
126
130
|
return;
|
|
127
131
|
}
|
|
128
|
-
if (retryCount > retryMaxAttempt) {
|
|
129
|
-
readyState.goTo(READY_STATES.CLOSED);
|
|
130
|
-
if (logs) {
|
|
131
|
-
console.info(
|
|
132
|
-
`[jsenv] could not connect to server after ${retryMaxAttempt} attempt`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
if (retryAllocatedMs && msSpent > retryAllocatedMs) {
|
|
138
|
-
readyState.goTo(READY_STATES.CLOSED);
|
|
139
|
-
if (logs) {
|
|
140
|
-
console.info(
|
|
141
|
-
`[jsenv] could not connect to server in less than ${retryAllocatedMs}ms`,
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
132
|
// if closed while open -> connection lost
|
|
147
133
|
// otherwise it's the attempt to connect for the first time
|
|
148
134
|
// or to reconnect
|
|
@@ -153,13 +139,13 @@ connection_manager: {
|
|
|
153
139
|
);
|
|
154
140
|
}
|
|
155
141
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}, retryAfter);
|
|
142
|
+
retryTimeout = setTimeout(attempt, retryDelay);
|
|
143
|
+
const nextRetryDelay = retryDelay * 2;
|
|
144
|
+
retryDelay =
|
|
145
|
+
nextRetryDelay > retryAfterMax ? retryAfterMax : nextRetryDelay;
|
|
161
146
|
},
|
|
162
147
|
onOpen: () => {
|
|
148
|
+
retryDelay = retryAfter;
|
|
163
149
|
readyState.goTo(READY_STATES.OPEN);
|
|
164
150
|
if (logs) {
|
|
165
151
|
// console.info(`[jsenv] connected to server`);
|
|
@@ -168,10 +154,19 @@ connection_manager: {
|
|
|
168
154
|
});
|
|
169
155
|
_disconnect = () => {
|
|
170
156
|
cancelAttempt();
|
|
171
|
-
clearTimeout(
|
|
157
|
+
clearTimeout(retryTimeout);
|
|
158
|
+
retryTimeout = null;
|
|
172
159
|
readyState.goTo(READY_STATES.CLOSED);
|
|
173
160
|
};
|
|
174
161
|
};
|
|
162
|
+
_retryNow = () => {
|
|
163
|
+
if (retryTimeout === null) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
clearTimeout(retryTimeout);
|
|
167
|
+
retryDelay = retryAfter;
|
|
168
|
+
attempt();
|
|
169
|
+
};
|
|
175
170
|
attempt();
|
|
176
171
|
};
|
|
177
172
|
|
|
@@ -198,6 +193,9 @@ connection_manager: {
|
|
|
198
193
|
_disconnect();
|
|
199
194
|
}
|
|
200
195
|
});
|
|
196
|
+
const removeServerMightBeBackListeners = listenServerMightBeBack(() => {
|
|
197
|
+
_retryNow();
|
|
198
|
+
});
|
|
201
199
|
|
|
202
200
|
return {
|
|
203
201
|
readyState,
|
|
@@ -205,11 +203,31 @@ connection_manager: {
|
|
|
205
203
|
disconnect,
|
|
206
204
|
destroy: () => {
|
|
207
205
|
removePageUnloadListener();
|
|
206
|
+
removeServerMightBeBackListeners();
|
|
208
207
|
disconnect();
|
|
209
208
|
},
|
|
210
209
|
};
|
|
211
210
|
};
|
|
212
211
|
|
|
212
|
+
const listenServerMightBeBack = (callback) => {
|
|
213
|
+
const removeOnlineListener = listenEvent(window, "online", callback);
|
|
214
|
+
const removeFocusListener = listenEvent(window, "focus", callback);
|
|
215
|
+
const removeVisibilityChangeListener = listenEvent(
|
|
216
|
+
document,
|
|
217
|
+
"visibilitychange",
|
|
218
|
+
() => {
|
|
219
|
+
if (document.visibilityState === "visible") {
|
|
220
|
+
callback();
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
return () => {
|
|
225
|
+
removeOnlineListener();
|
|
226
|
+
removeFocusListener();
|
|
227
|
+
removeVisibilityChangeListener();
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
|
|
213
231
|
// const listenPageMightFreeze = (callback) => {
|
|
214
232
|
// const removePageHideListener = listenEvent(window, "pagehide", (pageHideEvent) => {
|
|
215
233
|
// if (pageHideEvent.persisted === true) {
|
|
@@ -275,8 +293,7 @@ connection_using_websocket: {
|
|
|
275
293
|
useEventsToManageConnection = true,
|
|
276
294
|
retry = false,
|
|
277
295
|
retryAfter = 1000,
|
|
278
|
-
|
|
279
|
-
retryAllocatedMs = Infinity,
|
|
296
|
+
retryAfterMax = 5000,
|
|
280
297
|
} = {},
|
|
281
298
|
) => {
|
|
282
299
|
const connectionManager = createConnectionManager(
|
|
@@ -319,7 +336,7 @@ connection_using_websocket: {
|
|
|
319
336
|
}
|
|
320
337
|
};
|
|
321
338
|
},
|
|
322
|
-
{ logs, retry, retryAfter,
|
|
339
|
+
{ logs, retry, retryAfter, retryAfterMax },
|
|
323
340
|
);
|
|
324
341
|
const eventsManager = createEventsManager({
|
|
325
342
|
effect: () => {
|
|
@@ -443,7 +460,6 @@ const serverEventsInterface = {
|
|
|
443
460
|
const websocketConnection = createWebSocketConnection(websocketUrl, {
|
|
444
461
|
logs,
|
|
445
462
|
retry: true,
|
|
446
|
-
retryAllocatedMs: 10_000,
|
|
447
463
|
});
|
|
448
464
|
|
|
449
465
|
const { readyState, connect, disconnect, listenEvents } =
|