@lowdefy/server 6.1.0 → 7.1.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.
- package/client/App.jsx +41 -21
- package/client/Page.jsx +90 -26
- package/client/clearReloadedForTypes.js +29 -0
- package/client/clientBuildImports.test.mjs +6 -7
- package/client/getShownPage.js +28 -0
- package/client/getShownPage.test.mjs +52 -0
- package/client/loadAllIcons.js +45 -0
- package/client/loadPageTypes.js +44 -0
- package/client/main.jsx +22 -1
- package/client/reloadedForTypesKey.js +21 -0
- package/client/shouldReloadForTypes.js +38 -0
- package/client/shouldReloadForTypes.test.mjs +63 -0
- package/client/types.js +28 -0
- package/lib/build/security.js +26 -0
- package/lib/client/auth/Auth.jsx +11 -6
- package/lib/client/auth/AuthConfigured.jsx +222 -36
- package/lib/client/auth/AuthNotConfigured.js +44 -4
- package/lib/client/auth/createFetchCheckingBuild.js +45 -0
- package/lib/client/auth/createFetchCheckingBuild.test.mjs +100 -0
- package/lib/client/bundleBuildId.js +23 -0
- package/lib/client/createLogUsage.js +46 -25
- package/lib/client/createLogUsage.test.mjs +90 -0
- package/lib/client/getCspNonce.js +25 -0
- package/lib/server/auth/createSystemContext.js +70 -0
- package/lib/server/auth/{getAuthConfig.js → getAuth.js} +15 -9
- package/lib/server/auth/getStrategies.js +40 -0
- package/lib/server/getClientAddress.js +56 -0
- package/lib/server/getClientAddress.test.mjs +108 -0
- package/lib/server/log/createHandleError.js +7 -0
- package/lib/server/log/createHandleError.test.mjs +17 -0
- package/lib/server/log/createLogger.test.mjs +15 -0
- package/lib/server/log/logRequest.js +5 -3
- package/lib/server/log/projectErrorForLog.js +6 -0
- package/lib/server/log/serializeErrorForLog.test.mjs +13 -1
- package/lib/server/sentry/captureSentryError.js +17 -3
- package/lib/server/sentry/captureSentryError.test.mjs +37 -13
- package/lib/server/sentry/initSentry.js +39 -18
- package/lib/server/sentry/initSentry.serverless.test.mjs +165 -0
- package/lib/server/sentry/initSentry.test.mjs +132 -4
- package/lib/server/sentry/scrubEvent.js +12 -1
- package/lib/server/sentry/sentrySdk.js +23 -0
- package/lib/server/sentry/setSentryUser.js +3 -3
- package/lowdefy/build.mjs +2 -0
- package/lowdefy/build.test.mjs +88 -0
- package/lowdefy/createCustomPluginTypesMap.mjs +3 -2
- package/package.json +42 -36
- package/package.original.json +42 -36
- package/src/app.js +74 -17
- package/src/app.test.mjs +118 -0
- package/src/html/collectChunkClosure.js +31 -0
- package/src/html/collectLazyImports.js +41 -0
- package/src/html/collectPageTypesAssets.js +57 -0
- package/src/html/collectPageTypesAssets.test.mjs +130 -0
- package/src/html/getAssets.js +20 -1
- package/src/html/getHomePath.js +29 -0
- package/src/html/getPageAssets.js +32 -0
- package/src/html/getPageAssets.test.mjs +47 -0
- package/src/html/renderPage.contentSecurityPolicy.test.mjs +134 -0
- package/src/html/renderPage.js +80 -19
- package/src/html/renderPage.test.mjs +189 -0
- package/src/html/template.js +50 -15
- package/src/html/template.test.mjs +126 -0
- package/src/index.js +28 -29
- package/src/initServer.js +61 -0
- package/src/initServer.test.mjs +116 -0
- package/src/lib/getRequestPath.js +29 -0
- package/src/middleware/apiContext.js +65 -13
- package/src/middleware/apiContext.test.mjs +66 -6
- package/src/middleware/createSameOriginGuard.js +36 -0
- package/src/middleware/createSameOriginGuard.test.mjs +102 -0
- package/src/middleware/errorHandler.js +56 -4
- package/src/middleware/errorHandler.test.mjs +440 -0
- package/{lib/server/auth/session.js → src/middleware/noStore.js} +9 -13
- package/src/middleware/refuseOtherBuild.js +49 -0
- package/src/middleware/refuseOtherBuild.test.mjs +63 -0
- package/src/middleware/requestTimeout.js +38 -0
- package/src/middleware/sentry.js +13 -5
- package/src/middleware/stampBuildId.js +30 -0
- package/src/middleware/stampBuildId.test.mjs +41 -0
- package/src/middleware/stampCaller.js +38 -0
- package/src/middleware/stampCaller.test.mjs +64 -0
- package/src/routes/agent.js +15 -3
- package/src/routes/apiPage.js +51 -9
- package/src/routes/apiPage.test.mjs +99 -15
- package/src/routes/auth.js +15 -9
- package/src/routes/auth.test.mjs +85 -0
- package/src/routes/clientError.js +11 -13
- package/src/routes/cron.js +3 -1
- package/src/routes/cronForward.js +3 -1
- package/src/routes/detached.js +22 -9
- package/src/routes/endpoints.js +27 -26
- package/src/routes/endpointsTextAnswer.test.mjs +77 -0
- package/src/routes/mcp.js +82 -5
- package/src/routes/mcp.test.mjs +215 -0
- package/src/routes/mcpMemberToken.test.mjs +125 -0
- package/src/routes/mcpProtectedResourceMetadata.js +34 -0
- package/src/routes/mcpProtectedResourceMetadata.test.mjs +65 -0
- package/src/routes/mountOauthDiscovery.js +44 -0
- package/src/routes/mountOauthDiscovery.test.mjs +79 -0
- package/src/routes/mountPageRoutes.js +29 -0
- package/src/routes/mountPageRoutes.test.mjs +56 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.js +46 -0
- package/src/routes/normalizeMcpProtocolVersionHeader.test.mjs +62 -0
- package/src/routes/request.js +5 -3
- package/src/routes/requestBody.test.mjs +146 -0
- package/src/routes/usage.js +6 -2
- package/src/routes/user.js +27 -0
- package/src/routes/websocket.js +26 -3
- package/src/routes/websocket.test.mjs +106 -0
- package/src/routes/wellKnownFallback.js +30 -0
- package/vite.config.js +55 -2
- package/client/shouldReloadForBuild.js +0 -48
- package/client/shouldReloadForBuild.test.mjs +0 -75
package/src/routes/websocket.js
CHANGED
|
@@ -15,20 +15,31 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { upgradeWebSocket } from '@hono/node-server';
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
createChannelRegistry,
|
|
20
|
+
createWebSocketConnection,
|
|
21
|
+
isWebSocketOriginAllowed,
|
|
22
|
+
} from '@lowdefy/api';
|
|
23
|
+
|
|
24
|
+
import getStrategies from '../../lib/server/auth/getStrategies.js';
|
|
19
25
|
|
|
20
26
|
// One registry per server process — channels and their running source
|
|
21
27
|
// resolvers are shared across all websocket connections on this instance.
|
|
22
28
|
const registry = createChannelRegistry();
|
|
23
29
|
|
|
24
|
-
const
|
|
30
|
+
const upgrade = upgradeWebSocket((c) => {
|
|
25
31
|
const context = c.get('lowdefyContext');
|
|
26
32
|
let connection;
|
|
27
33
|
return {
|
|
28
34
|
onOpen(event, ws) {
|
|
35
|
+
// The upgrade's headers stay with the connection: its caller is resolved
|
|
36
|
+
// from them again while the socket is open.
|
|
29
37
|
connection = createWebSocketConnection(context, {
|
|
38
|
+
closeSocket: ({ code, reason }) => ws.close(code, reason),
|
|
39
|
+
headers: c.req.raw.headers,
|
|
30
40
|
registry,
|
|
31
41
|
send: (message) => ws.send(message),
|
|
42
|
+
strategies: getStrategies({ logger: context.logger }),
|
|
32
43
|
});
|
|
33
44
|
},
|
|
34
45
|
onMessage(event) {
|
|
@@ -38,9 +49,21 @@ const websocketHandler = upgradeWebSocket((c) => {
|
|
|
38
49
|
connection?.close();
|
|
39
50
|
},
|
|
40
51
|
onError(event) {
|
|
41
|
-
|
|
52
|
+
// A transport fault, not routine traffic: visible at the default level with
|
|
53
|
+
// the error object attached so the cause is not reduced to a string.
|
|
54
|
+
context.logger.warn({ event: 'ws_error', err: event?.error }, 'WebSocket error.');
|
|
42
55
|
},
|
|
43
56
|
};
|
|
44
57
|
});
|
|
45
58
|
|
|
59
|
+
// Upgrades are accepted from the app's own pages and from clients that send no
|
|
60
|
+
// Origin; isWebSocketOriginAllowed holds the rule and logs a refusal.
|
|
61
|
+
function websocketHandler(c, next) {
|
|
62
|
+
const context = c.get('lowdefyContext');
|
|
63
|
+
if (!isWebSocketOriginAllowed({ context, getHeader: (name) => c.req.header(name) })) {
|
|
64
|
+
return c.json({ error: 'Forbidden' }, 403);
|
|
65
|
+
}
|
|
66
|
+
return upgrade(c, next);
|
|
67
|
+
}
|
|
68
|
+
|
|
46
69
|
export default websocketHandler;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Hono } from 'hono';
|
|
18
|
+
import { jest } from '@jest/globals';
|
|
19
|
+
import { serve } from '@hono/node-server';
|
|
20
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
21
|
+
|
|
22
|
+
jest.unstable_mockModule('../../lib/server/auth/getStrategies.js', () => ({
|
|
23
|
+
default: () => [],
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const { default: websocketHandler } = await import('./websocket.js');
|
|
27
|
+
|
|
28
|
+
let server;
|
|
29
|
+
let port;
|
|
30
|
+
|
|
31
|
+
// The production wiring (src/index.js): a Hono app under the app's basePath,
|
|
32
|
+
// served by @hono/node-server with a ws server for upgrades. The Vercel
|
|
33
|
+
// function runs the same app through the same upgrade support.
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
const app = new Hono().basePath('/app');
|
|
36
|
+
app.use('*', async (c, next) => {
|
|
37
|
+
// The caller the upgrade resolved: signed in when the test says so.
|
|
38
|
+
const signedIn = c.req.header('x-test-signed-in') === 'true';
|
|
39
|
+
c.set('lowdefyContext', {
|
|
40
|
+
// The auth engine finds no session for the socket's cookie on a re-check.
|
|
41
|
+
auth: { api: { getSession: async () => null } },
|
|
42
|
+
config: {},
|
|
43
|
+
i18n: { active: 'en-US', messages: {} },
|
|
44
|
+
rid: 'request',
|
|
45
|
+
logger: { debug: () => {}, info: () => {}, warn: () => {} },
|
|
46
|
+
handleError: () => {},
|
|
47
|
+
user: signedIn ? { id: 'user-1', organization_id: 'org-1', roles: [] } : null,
|
|
48
|
+
});
|
|
49
|
+
await next();
|
|
50
|
+
});
|
|
51
|
+
app.get('/api/websocket', websocketHandler);
|
|
52
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
53
|
+
await new Promise((resolve) => {
|
|
54
|
+
server = serve({ fetch: app.fetch, port: 0, websocket: { server: wss } }, (info) => {
|
|
55
|
+
port = info.port;
|
|
56
|
+
resolve();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(() => {
|
|
62
|
+
server.close();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function open({ headers }) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
const socket = new WebSocket(`ws://127.0.0.1:${port}/app/api/websocket`, { headers });
|
|
68
|
+
socket.once('open', () => {
|
|
69
|
+
socket.close();
|
|
70
|
+
resolve('open');
|
|
71
|
+
});
|
|
72
|
+
socket.once('unexpected-response', (_, response) => {
|
|
73
|
+
socket.terminate();
|
|
74
|
+
resolve(response.statusCode);
|
|
75
|
+
});
|
|
76
|
+
socket.once('error', () => {});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test.each([
|
|
81
|
+
['opens', 'the page is served from the same host', { origin: 'https://app.test' }, 'open'],
|
|
82
|
+
['opens', 'a client sends no origin', {}, 'open'],
|
|
83
|
+
['is refused', 'the origin is another site', { origin: 'https://other.test' }, 403],
|
|
84
|
+
[
|
|
85
|
+
'opens',
|
|
86
|
+
'a proxy rewrote Host and passed X-Forwarded-Host',
|
|
87
|
+
{ host: 'localhost:3000', 'x-forwarded-host': 'app.test', origin: 'https://app.test' },
|
|
88
|
+
'open',
|
|
89
|
+
],
|
|
90
|
+
])('a websocket upgrade %s when %s', async (_, __, headers, expected) => {
|
|
91
|
+
expect(await open({ headers: { host: 'app.test', ...headers } })).toEqual(expected);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a websocket whose session ended closes with 4401 on its next subscribe', async () => {
|
|
95
|
+
const socket = new WebSocket(`ws://127.0.0.1:${port}/app/api/websocket`, {
|
|
96
|
+
headers: { host: 'app.test', 'x-test-signed-in': 'true' },
|
|
97
|
+
});
|
|
98
|
+
await new Promise((resolve) => socket.once('open', resolve));
|
|
99
|
+
const closed = new Promise((resolve) => {
|
|
100
|
+
socket.once('close', (code, reason) => resolve({ code, reason: reason.toString() }));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
socket.send(JSON.stringify({ type: 'subscribe', websocketId: 'ticker', requestId: 's1' }));
|
|
104
|
+
|
|
105
|
+
expect(await closed).toEqual({ code: 4401, reason: 'caller-changed' });
|
|
106
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// RFC 8615 well-known URIs are machine-facing: a path this deployment does not
|
|
18
|
+
// serve must answer 404, never the app's HTML shell. Serving 200 HTML breaks
|
|
19
|
+
// clients that probe discovery documents - an MCP client's OAuth probe, for
|
|
20
|
+
// example, fails with "Failed to parse JSON" instead of learning there is no
|
|
21
|
+
// auth to discover. Mounted after the real discovery documents, before the
|
|
22
|
+
// page catch-all.
|
|
23
|
+
function wellKnownFallbackHandler(c) {
|
|
24
|
+
return c.json(
|
|
25
|
+
{ name: 'NotFoundError', message: `No well-known document at ${c.req.path}.` },
|
|
26
|
+
404
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default wellKnownFallbackHandler;
|
package/vite.config.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import fs from 'node:fs';
|
|
18
18
|
import { defineConfig } from 'vite';
|
|
19
19
|
import react from '@vitejs/plugin-react';
|
|
20
|
+
import { Features } from 'lightningcss';
|
|
20
21
|
import { sentryVitePlugin } from '@sentry/vite-plugin';
|
|
21
22
|
|
|
22
23
|
// basePath from the Lowdefy build — assets are served under it.
|
|
@@ -28,12 +29,41 @@ try {
|
|
|
28
29
|
// No build yet (e.g. editor tooling) — default base.
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
// Vite 8 tree-shakes a `.module.css` imported only for its side effects
|
|
33
|
+
// (`import './style.module.css'`), and its CSS goes with it: `:global(...)` rules in block and
|
|
34
|
+
// plugin stylesheets never reach the built client. Marking CSS modules as side-effectful keeps
|
|
35
|
+
// them, whatever the import style.
|
|
36
|
+
function keepCssModuleSideEffects() {
|
|
37
|
+
return {
|
|
38
|
+
name: 'lowdefy-keep-css-module-side-effects',
|
|
39
|
+
enforce: 'post',
|
|
40
|
+
transform(code, id) {
|
|
41
|
+
if (/\.module\.css($|\?)/.test(id)) {
|
|
42
|
+
return { code, moduleSideEffects: 'no-treeshake' };
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
const uploadSourceMaps = Boolean(process.env.SENTRY_AUTH_TOKEN);
|
|
32
49
|
|
|
50
|
+
// The @lowdefy packages this server depends on. A linked plugin pinned to
|
|
51
|
+
// another release brings its own copies, and the client would then mix
|
|
52
|
+
// versions: the dev server pre-bundles one copy per package for every
|
|
53
|
+
// importer (whichever its scan meets first, so the server's own code can get
|
|
54
|
+
// a plugin's older @lowdefy/helpers without an export it imports), and a
|
|
55
|
+
// production build bundles both, so blocks read module state the client never
|
|
56
|
+
// filled (the HTML enhancements it registers in @lowdefy/block-utils). The
|
|
57
|
+
// server's copies are the ones its client and blocks were released with.
|
|
58
|
+
const lowdefyDependencies = Object.keys(
|
|
59
|
+
JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).dependencies ?? {}
|
|
60
|
+
).filter((name) => name.startsWith('@lowdefy/'));
|
|
61
|
+
|
|
33
62
|
export default defineConfig(({ mode }) => ({
|
|
34
63
|
base: `${basePath}/`,
|
|
35
64
|
plugins: [
|
|
36
65
|
react(),
|
|
66
|
+
keepCssModuleSideEffects(),
|
|
37
67
|
uploadSourceMaps &&
|
|
38
68
|
sentryVitePlugin({
|
|
39
69
|
org: process.env.SENTRY_ORG,
|
|
@@ -46,6 +76,17 @@ export default defineConfig(({ mode }) => ({
|
|
|
46
76
|
// plugin and client code branch on it.
|
|
47
77
|
'process.env.NODE_ENV': JSON.stringify(mode === 'production' ? 'production' : 'development'),
|
|
48
78
|
},
|
|
79
|
+
css: {
|
|
80
|
+
// Vite minifies client CSS with Lightning CSS, which by default lowers
|
|
81
|
+
// light-dark() into --lightningcss-light/--lightningcss-dark variables that
|
|
82
|
+
// only a CSS color-scheme declaration defines. The client sets color-scheme
|
|
83
|
+
// from JavaScript (useDarkMode), so the lowered values never resolve and
|
|
84
|
+
// every light-dark() colour is dropped. Tailwind leaves light-dark()
|
|
85
|
+
// unlowered in its own Lightning CSS pass for the same reason.
|
|
86
|
+
lightningcss: {
|
|
87
|
+
exclude: Features.LightDark,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
49
90
|
build: {
|
|
50
91
|
outDir: 'dist/client',
|
|
51
92
|
manifest: true,
|
|
@@ -55,7 +96,19 @@ export default defineConfig(({ mode }) => ({
|
|
|
55
96
|
},
|
|
56
97
|
},
|
|
57
98
|
resolve: {
|
|
58
|
-
//
|
|
59
|
-
|
|
99
|
+
// Linked plugin packages (pnpm link: / workspace) resolve their own copies of
|
|
100
|
+
// shared libraries. They must share one React, the antd/X/cssinjs copies
|
|
101
|
+
// whose context providers (App, ConfigProvider, StyleProvider) the client
|
|
102
|
+
// renders, the dayjs whose global locale the client sets, and the server's
|
|
103
|
+
// @lowdefy packages (see lowdefyDependencies).
|
|
104
|
+
dedupe: [
|
|
105
|
+
'react',
|
|
106
|
+
'react-dom',
|
|
107
|
+
'antd',
|
|
108
|
+
'@ant-design/x',
|
|
109
|
+
'@ant-design/cssinjs',
|
|
110
|
+
'dayjs',
|
|
111
|
+
...lowdefyDependencies,
|
|
112
|
+
],
|
|
60
113
|
},
|
|
61
114
|
}));
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
-
|
|
4
|
-
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
you may not use this file except in compliance with the License.
|
|
6
|
-
You may obtain a copy of the License at
|
|
7
|
-
|
|
8
|
-
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
|
|
10
|
-
Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
See the License for the specific language governing permissions and
|
|
14
|
-
limitations under the License.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { type } from '@lowdefy/helpers';
|
|
18
|
-
|
|
19
|
-
// sessionStorage key holding the server build this tab last reloaded for.
|
|
20
|
-
const RELOADED_FOR_BUILD_KEY = 'lowdefy.reloadedForBuild';
|
|
21
|
-
|
|
22
|
-
// True when a page config fetched during SPA navigation came from a newer
|
|
23
|
-
// deploy than the bundle this tab is running, so the tab must do a full load
|
|
24
|
-
// to pick up the current bundle. Without this the new config can reference
|
|
25
|
-
// _js functions the old bundle never shipped, and the page throws mid-event.
|
|
26
|
-
//
|
|
27
|
-
// A tab reloads at most once per server build: if the freshly loaded bundle
|
|
28
|
-
// still disagrees with the server (a rolling deploy answering from mixed
|
|
29
|
-
// versions, a proxy caching old HTML), reloading again would loop forever.
|
|
30
|
-
// The old-config-on-old-bundle throw is the lesser failure in that case.
|
|
31
|
-
function shouldReloadForBuild({ bundleBuildId, serverBuildId, window }) {
|
|
32
|
-
if (type.isNone(serverBuildId) || serverBuildId === bundleBuildId) {
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
try {
|
|
36
|
-
if (window.sessionStorage.getItem(RELOADED_FOR_BUILD_KEY) === serverBuildId) {
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
window.sessionStorage.setItem(RELOADED_FOR_BUILD_KEY, serverBuildId);
|
|
40
|
-
} catch (error) {
|
|
41
|
-
// Browsers with storage disabled throw on sessionStorage access. Without
|
|
42
|
-
// the once-per-build record the loop guard cannot work, so do not reload.
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
return true;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export default shouldReloadForBuild;
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
-
|
|
4
|
-
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
you may not use this file except in compliance with the License.
|
|
6
|
-
You may obtain a copy of the License at
|
|
7
|
-
|
|
8
|
-
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
|
|
10
|
-
Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
See the License for the specific language governing permissions and
|
|
14
|
-
limitations under the License.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import shouldReloadForBuild from './shouldReloadForBuild.js';
|
|
18
|
-
|
|
19
|
-
function createWindow({ stored = {}, throws = false } = {}) {
|
|
20
|
-
return {
|
|
21
|
-
sessionStorage: {
|
|
22
|
-
getItem: (key) => {
|
|
23
|
-
if (throws) throw new Error('storage disabled');
|
|
24
|
-
return stored[key] ?? null;
|
|
25
|
-
},
|
|
26
|
-
setItem: (key, value) => {
|
|
27
|
-
if (throws) throw new Error('storage disabled');
|
|
28
|
-
stored[key] = value;
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
test('shouldReloadForBuild is false when the server build matches the bundle', () => {
|
|
35
|
-
const window = createWindow();
|
|
36
|
-
expect(shouldReloadForBuild({ bundleBuildId: 'build-1', serverBuildId: 'build-1', window })).toBe(
|
|
37
|
-
false
|
|
38
|
-
);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test('shouldReloadForBuild is false when the server sends no build id', () => {
|
|
42
|
-
const window = createWindow();
|
|
43
|
-
expect(shouldReloadForBuild({ bundleBuildId: 'build-1', serverBuildId: undefined, window })).toBe(
|
|
44
|
-
false
|
|
45
|
-
);
|
|
46
|
-
expect(shouldReloadForBuild({ bundleBuildId: 'build-1', serverBuildId: null, window })).toBe(
|
|
47
|
-
false
|
|
48
|
-
);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test('shouldReloadForBuild is true once per newer server build, then false', () => {
|
|
52
|
-
const stored = {};
|
|
53
|
-
const window = createWindow({ stored });
|
|
54
|
-
expect(shouldReloadForBuild({ bundleBuildId: 'build-1', serverBuildId: 'build-2', window })).toBe(
|
|
55
|
-
true
|
|
56
|
-
);
|
|
57
|
-
expect(stored['lowdefy.reloadedForBuild']).toEqual('build-2');
|
|
58
|
-
expect(shouldReloadForBuild({ bundleBuildId: 'build-1', serverBuildId: 'build-2', window })).toBe(
|
|
59
|
-
false
|
|
60
|
-
);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test('shouldReloadForBuild reloads again when the server moves to a third build', () => {
|
|
64
|
-
const window = createWindow({ stored: { 'lowdefy.reloadedForBuild': 'build-2' } });
|
|
65
|
-
expect(shouldReloadForBuild({ bundleBuildId: 'build-2', serverBuildId: 'build-3', window })).toBe(
|
|
66
|
-
true
|
|
67
|
-
);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test('shouldReloadForBuild is false when sessionStorage is unavailable', () => {
|
|
71
|
-
const window = createWindow({ throws: true });
|
|
72
|
-
expect(shouldReloadForBuild({ bundleBuildId: 'build-1', serverBuildId: 'build-2', window })).toBe(
|
|
73
|
-
false
|
|
74
|
-
);
|
|
75
|
-
});
|