lupine.api 1.0.41

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 (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -0
  3. package/admin/admin-about.tsx +16 -0
  4. package/admin/admin-config.tsx +44 -0
  5. package/admin/admin-css.tsx +3 -0
  6. package/admin/admin-db.tsx +74 -0
  7. package/admin/admin-frame-props.tsx +9 -0
  8. package/admin/admin-frame.tsx +466 -0
  9. package/admin/admin-index.tsx +66 -0
  10. package/admin/admin-login.tsx +99 -0
  11. package/admin/admin-menu-edit.tsx +637 -0
  12. package/admin/admin-menu-list.tsx +87 -0
  13. package/admin/admin-page-edit.tsx +564 -0
  14. package/admin/admin-page-list.tsx +83 -0
  15. package/admin/admin-performance.tsx +28 -0
  16. package/admin/admin-release.tsx +320 -0
  17. package/admin/admin-resources.tsx +385 -0
  18. package/admin/admin-shell.tsx +89 -0
  19. package/admin/admin-table-data.tsx +146 -0
  20. package/admin/admin-table-list.tsx +231 -0
  21. package/admin/admin-test-animations.tsx +379 -0
  22. package/admin/admin-test-component.tsx +808 -0
  23. package/admin/admin-test-edit.tsx +319 -0
  24. package/admin/admin-test-themes.tsx +56 -0
  25. package/admin/admin-tokens.tsx +338 -0
  26. package/admin/design/admin-design.tsx +174 -0
  27. package/admin/design/block-grid.tsx +36 -0
  28. package/admin/design/block-grid1.tsx +21 -0
  29. package/admin/design/block-paragraph.tsx +19 -0
  30. package/admin/design/block-title.tsx +19 -0
  31. package/admin/design/design-block-box.tsx +140 -0
  32. package/admin/design/drag-data.tsx +24 -0
  33. package/admin/index.ts +6 -0
  34. package/admin/package.json +15 -0
  35. package/admin/tsconfig.json +127 -0
  36. package/dev/copy-folder.js +32 -0
  37. package/dev/cp-index-html.js +69 -0
  38. package/dev/file-utils.js +12 -0
  39. package/dev/index.js +19 -0
  40. package/dev/package.json +12 -0
  41. package/dev/plugin-gen-versions.js +20 -0
  42. package/dev/plugin-ifelse.js +155 -0
  43. package/dev/plugin-ifelse.test.js +37 -0
  44. package/dev/run-cmd.js +14 -0
  45. package/dev/send-request.js +12 -0
  46. package/package.json +55 -0
  47. package/src/admin-api/admin-api.ts +59 -0
  48. package/src/admin-api/admin-auth.ts +87 -0
  49. package/src/admin-api/admin-config.ts +93 -0
  50. package/src/admin-api/admin-csv.ts +81 -0
  51. package/src/admin-api/admin-db.ts +269 -0
  52. package/src/admin-api/admin-helper.ts +111 -0
  53. package/src/admin-api/admin-menu.ts +135 -0
  54. package/src/admin-api/admin-page.ts +135 -0
  55. package/src/admin-api/admin-performance.ts +128 -0
  56. package/src/admin-api/admin-release.ts +498 -0
  57. package/src/admin-api/admin-resources.ts +318 -0
  58. package/src/admin-api/admin-token-helper.ts +79 -0
  59. package/src/admin-api/admin-tokens.ts +90 -0
  60. package/src/admin-api/index.ts +2 -0
  61. package/src/api/api-cache.ts +103 -0
  62. package/src/api/api-helper.ts +44 -0
  63. package/src/api/api-module.ts +60 -0
  64. package/src/api/api-router.ts +177 -0
  65. package/src/api/api-shared-storage.ts +64 -0
  66. package/src/api/async-storage.ts +5 -0
  67. package/src/api/debug-service.ts +56 -0
  68. package/src/api/encode-html.ts +27 -0
  69. package/src/api/handle-status.ts +71 -0
  70. package/src/api/index.ts +16 -0
  71. package/src/api/mini-web-socket.ts +270 -0
  72. package/src/api/server-content-type.ts +82 -0
  73. package/src/api/server-render.ts +216 -0
  74. package/src/api/shell-service.ts +66 -0
  75. package/src/api/simple-storage.ts +80 -0
  76. package/src/api/static-server.ts +125 -0
  77. package/src/api/to-client-delivery.ts +26 -0
  78. package/src/app/app-cache.ts +55 -0
  79. package/src/app/app-loader.ts +62 -0
  80. package/src/app/app-message.ts +60 -0
  81. package/src/app/app-shared-storage.ts +317 -0
  82. package/src/app/app-start.ts +117 -0
  83. package/src/app/cleanup-exit.ts +12 -0
  84. package/src/app/host-to-path.ts +38 -0
  85. package/src/app/index.ts +11 -0
  86. package/src/app/process-dev-requests.ts +90 -0
  87. package/src/app/web-listener.ts +230 -0
  88. package/src/app/web-processor.ts +42 -0
  89. package/src/app/web-server.ts +86 -0
  90. package/src/common-js/web-env.js +104 -0
  91. package/src/index.ts +7 -0
  92. package/src/lang/api-lang-en.ts +27 -0
  93. package/src/lang/api-lang-zh-cn.ts +28 -0
  94. package/src/lang/index.ts +2 -0
  95. package/src/lang/lang-helper.ts +76 -0
  96. package/src/lang/lang-props.ts +6 -0
  97. package/src/lib/db/db-helper.ts +23 -0
  98. package/src/lib/db/db-mysql.ts +250 -0
  99. package/src/lib/db/db-sqlite.ts +101 -0
  100. package/src/lib/db/db.spec.ts +28 -0
  101. package/src/lib/db/db.ts +304 -0
  102. package/src/lib/db/index.ts +5 -0
  103. package/src/lib/index.ts +3 -0
  104. package/src/lib/logger.spec.ts +214 -0
  105. package/src/lib/logger.ts +274 -0
  106. package/src/lib/runtime-require.ts +37 -0
  107. package/src/lib/utils/cookie-util.ts +34 -0
  108. package/src/lib/utils/crypto.ts +58 -0
  109. package/src/lib/utils/date-utils.ts +317 -0
  110. package/src/lib/utils/deep-merge.ts +37 -0
  111. package/src/lib/utils/delay.ts +12 -0
  112. package/src/lib/utils/file-setting.ts +55 -0
  113. package/src/lib/utils/format-bytes.ts +11 -0
  114. package/src/lib/utils/fs-utils.ts +144 -0
  115. package/src/lib/utils/get-env.ts +27 -0
  116. package/src/lib/utils/index.ts +12 -0
  117. package/src/lib/utils/is-type.ts +48 -0
  118. package/src/lib/utils/load-env.ts +14 -0
  119. package/src/lib/utils/pad.ts +6 -0
  120. package/src/models/api-base.ts +5 -0
  121. package/src/models/api-module-props.ts +11 -0
  122. package/src/models/api-router-props.ts +26 -0
  123. package/src/models/app-cache-props.ts +33 -0
  124. package/src/models/app-data-props.ts +10 -0
  125. package/src/models/app-loader-props.ts +6 -0
  126. package/src/models/app-shared-storage-props.ts +37 -0
  127. package/src/models/app-start-props.ts +18 -0
  128. package/src/models/async-storage-props.ts +13 -0
  129. package/src/models/db-config.ts +30 -0
  130. package/src/models/host-to-path-props.ts +12 -0
  131. package/src/models/index.ts +16 -0
  132. package/src/models/json-object.ts +8 -0
  133. package/src/models/locals-props.ts +36 -0
  134. package/src/models/logger-props.ts +84 -0
  135. package/src/models/simple-storage-props.ts +14 -0
  136. package/src/models/to-client-delivery-props.ts +6 -0
  137. package/tsconfig.json +115 -0
@@ -0,0 +1,177 @@
1
+ import { ServerResponse } from 'http';
2
+ import { Logger } from '../lib';
3
+ import { ServerRequest } from '../models/locals-props';
4
+ import { handler404 } from './handle-status';
5
+ import { SimpleStorage } from './simple-storage';
6
+ import { ApiRouterCallback, ApiRouterData, ApiRouterMethod, IApiRouter } from '../models/api-router-props';
7
+ const logger = new Logger('api-router');
8
+
9
+ /*
10
+ If there is a common logic to be called all endpoints, then you can set filter.
11
+ Or, for a particular endpoint, if you want a logic to be called before any other
12
+ WebRouterCallback or WebRouter it can be done like this:
13
+
14
+ const commLogic: WebRouterCallback = async (req: ServerRequest, res: ServerResponse) => {
15
+ console.log('this is called by all...');
16
+ return false;
17
+ }
18
+ router.use('/auth', commLogic, otherLogic);
19
+ */
20
+
21
+ function isIApiRouter(handler: ApiRouterCallback | IApiRouter): handler is IApiRouter {
22
+ return (handler as IApiRouter).findRoute !== undefined;
23
+ }
24
+
25
+ export class ApiRouter implements IApiRouter {
26
+ private routerData: ApiRouterData[] = [];
27
+ private filter: ApiRouterCallback | undefined;
28
+
29
+ setFilter(filter: ApiRouterCallback) {
30
+ this.filter = filter;
31
+ }
32
+
33
+ // the path should start with / and end without /, and it can be
34
+ // /aaa/:bbb/ccc/:ddd (ccc is a fixed section)
35
+ // /aaa/:bbb/ccc/?ddd/?eee (from ddd, all sections are optional)
36
+ private storeRouter(path: string, handler: (ApiRouterCallback | IApiRouter)[], method: ApiRouterMethod) {
37
+ let fixedPath;
38
+ if (path === '*' || path === '/' || path === '' || path === '/*') {
39
+ fixedPath = '*';
40
+ } else {
41
+ fixedPath = path;
42
+ if (!fixedPath.startsWith('/')) {
43
+ fixedPath = '/' + fixedPath;
44
+ }
45
+ if (fixedPath.endsWith('/')) {
46
+ fixedPath = fixedPath.substring(0, fixedPath.length - 1);
47
+ }
48
+ }
49
+
50
+ let parameterLength = 0;
51
+ let parameterVariables: string[] = [];
52
+ const ind = fixedPath.indexOf('/:');
53
+ if (ind >= 0) {
54
+ parameterVariables = fixedPath.substring(ind + 1).split('/');
55
+ fixedPath = fixedPath.substring(0, ind);
56
+ // from optionInd, all will be optional
57
+ const optionInd = parameterVariables.findIndex((item) => item.startsWith('?'));
58
+ parameterLength = optionInd >= 0 ? optionInd : parameterVariables.length;
59
+ }
60
+
61
+ this.routerData.push({
62
+ path: fixedPath,
63
+ handler,
64
+ method: method,
65
+ parameterVariables,
66
+ parameterLength,
67
+ });
68
+ }
69
+
70
+ get(path: string, ...handler: (ApiRouterCallback | IApiRouter)[]) {
71
+ this.storeRouter(path, handler, ApiRouterMethod.GET);
72
+ }
73
+ post(path: string, ...handler: (ApiRouterCallback | IApiRouter)[]) {
74
+ this.storeRouter(path, handler, ApiRouterMethod.POST);
75
+ }
76
+
77
+ use(path: string, ...handler: (ApiRouterCallback | IApiRouter)[]) {
78
+ this.storeRouter(path, handler, ApiRouterMethod.ALL);
79
+ }
80
+
81
+ private async callHandle(
82
+ handle: ApiRouterCallback,
83
+ path: string,
84
+ req: ServerRequest,
85
+ res: ServerResponse
86
+ ): Promise<boolean> {
87
+ try {
88
+ if ((await handle(req, res, path)) || res.writableEnded) {
89
+ logger.debug(`Processed path: ${path}`);
90
+ return true;
91
+ }
92
+ } catch (e: any) {
93
+ logger.error(`Processed path: ${path}, error: ${e.message}`);
94
+ res.writeHead(500, { 'Content-Type': 'text/html' });
95
+ res.write(
96
+ JSON.stringify({
97
+ status: 'error',
98
+ message: `Processed path: ${path}, error: ${e.message}`,
99
+ })
100
+ );
101
+ res.end();
102
+ return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ async findRoute(url: string, req: ServerRequest, res: ServerResponse, handleNotFound: boolean): Promise<boolean> {
108
+ for (let i = 0, routerList; (routerList = this.routerData[i]); i++) {
109
+ if (
110
+ (routerList.method === ApiRouterMethod.ALL || routerList.method === req.method) &&
111
+ (routerList.path === '*' || url === routerList.path || url.startsWith(routerList.path + '/'))
112
+ ) {
113
+ const parameters: { [key: string]: string } = {};
114
+ let meet = true;
115
+ if (routerList.parameterVariables.length > 0) {
116
+ meet = false;
117
+ const restPath = url.substring(routerList.path.length + 1).split('/');
118
+ // the path must have mandatory parameters but some parameters can be optional
119
+ if (
120
+ restPath.length >= routerList.parameterLength &&
121
+ restPath.length <= routerList.parameterVariables.length
122
+ ) {
123
+ meet = true;
124
+ for (const [index, item] of routerList.parameterVariables.entries()) {
125
+ if (!item.startsWith(':') && !item.startsWith('?') && item !== restPath[index]) {
126
+ meet = false;
127
+ break;
128
+ } else if ((item.startsWith(':') || item.startsWith('?')) && index < restPath.length) {
129
+ parameters[item.replace(/[:?]/g, '')] = restPath[index];
130
+ }
131
+ }
132
+ req.locals.urlParameters = new SimpleStorage(parameters);
133
+ }
134
+ }
135
+
136
+ if (meet) {
137
+ for (let j = 0, router; (router = routerList.handler[j]); j++) {
138
+ if (isIApiRouter(router)) {
139
+ // it's a sub-level router
140
+ const nextPath = routerList.path === '*' ? url : url.substring(routerList.path.length);
141
+ // the sub-level router will not have the appName
142
+ if ((await router.findRoute(nextPath, req, res, handleNotFound)) || res.writableEnded) {
143
+ return true;
144
+ }
145
+ } else {
146
+ // it should be a function
147
+ // the query's url should match the api's path
148
+ if (await this.callHandle(router, url, req, res)) {
149
+ return true;
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ if (handleNotFound) {
156
+ // no match under this path
157
+ logger.debug(`Processed path: ${url}, router path: ${routerList.path}`);
158
+ const html = JSON.stringify({
159
+ status: 'error',
160
+ message: `Can't find any matches under router ${routerList.path} for path: ${url}.`,
161
+ });
162
+ handler404(res, html);
163
+ return true;
164
+ }
165
+ }
166
+ }
167
+ return false;
168
+ }
169
+
170
+ async handleRequest(url: string, req: ServerRequest, res: ServerResponse): Promise<boolean> {
171
+ if (this.filter && (await this.callHandle(this.filter, url, req, res))) {
172
+ return true;
173
+ }
174
+
175
+ return await this.findRoute(url, req, res, true);
176
+ }
177
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A persistent storage for the Api
3
+ */
4
+ import { IAppSharedStorage } from '../models';
5
+ import { SimpleStorageDataProps } from '../models';
6
+ import { apiCache } from './api-cache';
7
+
8
+ // ApiSharedStorage is used in api module to store variables inside an api scope
9
+ export class ApiSharedStorage {
10
+ private storage: IAppSharedStorage | undefined;
11
+
12
+ public setAppSharedStorage(storage: IAppSharedStorage): void {
13
+ this.storage = storage;
14
+ }
15
+ public getAppSharedStorage(): IAppSharedStorage {
16
+ if (!this.storage) {
17
+ throw new Error('AppSharedStorage not initialized');
18
+ }
19
+ return this.storage;
20
+ }
21
+
22
+ // called from primary before exit, or from api to save changes
23
+ async save() {
24
+ const appName = apiCache.getAppName();
25
+ await this.getAppSharedStorage().save(appName);
26
+ }
27
+
28
+ get(key: string): Promise<string> {
29
+ const appName = apiCache.getAppName();
30
+ return this.getAppSharedStorage().get(appName, key);
31
+ }
32
+ getWeb(key: string): Promise<string> {
33
+ const appName = apiCache.getAppName();
34
+ return this.getAppSharedStorage().getWeb(appName, key);
35
+ }
36
+ getApi(key: string): Promise<string> {
37
+ const appName = apiCache.getAppName();
38
+ return this.getAppSharedStorage().getApi(appName, key);
39
+ }
40
+ getWebAll(): Promise<SimpleStorageDataProps> {
41
+ const appName = apiCache.getAppName();
42
+ return this.getAppSharedStorage().getWebAll(appName);
43
+ }
44
+ getWithPrefix(prefixKey: string): Promise<SimpleStorageDataProps> {
45
+ const appName = apiCache.getAppName();
46
+ return this.getAppSharedStorage().getWithPrefix(appName, prefixKey);
47
+ }
48
+
49
+ set(key: string, value: any) {
50
+ const appName = apiCache.getAppName();
51
+ return this.getAppSharedStorage().set(appName, key, value);
52
+ }
53
+ setWeb(key: string, value: any) {
54
+ const appName = apiCache.getAppName();
55
+ return this.getAppSharedStorage().setWeb(appName, key, value);
56
+ }
57
+ setApi(key: string, value: any) {
58
+ const appName = apiCache.getAppName();
59
+ return this.getAppSharedStorage().setApi(appName, key, value);
60
+ }
61
+ }
62
+
63
+ // this can be used in app, but in api, it should use getAppStorage()
64
+ export const apiStorage = /* @__PURE__ */ new ApiSharedStorage();
@@ -0,0 +1,5 @@
1
+ // It is similar to a thread-local storage in other languages.
2
+ import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { AsyncStorageProps } from '../models/async-storage-props';
4
+
5
+ export const asyncLocalStorage = new AsyncLocalStorage<AsyncStorageProps>();
@@ -0,0 +1,56 @@
1
+ import { IncomingMessage } from 'http';
2
+ import { Duplex } from 'stream';
3
+ import { MiniWebSocket } from './mini-web-socket';
4
+ import { ShellService } from './shell-service';
5
+
6
+ // This is only used in debug mode (no clusters)
7
+ export class DebugService {
8
+ static clientRefreshFlag = Date.now();
9
+ static miniWebSocket = new MiniWebSocket(this.onMessage.bind(this));
10
+ static shellMap = new Map<Duplex, ShellService>();
11
+
12
+ public static onMessage(msg: string, socket: Duplex) {
13
+ try {
14
+ const json = JSON.parse(msg);
15
+ if (json.message === 'get-flag') {
16
+ this.miniWebSocket.sendMessage(
17
+ socket,
18
+ JSON.stringify({
19
+ message: 'flag',
20
+ flag: DebugService.clientRefreshFlag,
21
+ })
22
+ );
23
+ } else if (json.message === 'shell') {
24
+ console.log(json);
25
+ let shell: ShellService;
26
+ if (this.shellMap.has(socket)) {
27
+ shell = this.shellMap.get(socket)!;
28
+ } else {
29
+ shell = new ShellService(socket, this.miniWebSocket);
30
+ this.shellMap.set(socket, shell);
31
+ socket.on('close', () => {
32
+ shell.stop();
33
+ this.shellMap.delete(socket);
34
+ });
35
+ }
36
+ shell.cmd(json.cmd);
37
+ }
38
+ } catch (error) {
39
+ console.error(error);
40
+ }
41
+ }
42
+
43
+ public static handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer) {
44
+ this.miniWebSocket.handleUpgrade(req, socket, head);
45
+
46
+ // socket.write(JSON.stringify({ message: 'flag', flag: DebugService.clientRefreshFlag }));
47
+ }
48
+
49
+ // broadcast to all frontend clients
50
+ public static broadcastRefresh() {
51
+ console.log(`broadcast refresh request to clients.`);
52
+ this.clientRefreshFlag = Date.now();
53
+ const msg = { message: 'Refresh', flag: this.clientRefreshFlag };
54
+ this.miniWebSocket.broadcast(JSON.stringify(msg));
55
+ }
56
+ }
@@ -0,0 +1,27 @@
1
+ export const encodeHtml = (str: string): string => {
2
+ return str.replace(
3
+ /[&<>'"]/g,
4
+ (tag) =>
5
+ ({
6
+ '&': '&amp;',
7
+ '<': '&lt;',
8
+ '>': '&gt;',
9
+ "'": '&#39;',
10
+ '"': '&quot;',
11
+ }[tag] || '')
12
+ );
13
+ };
14
+
15
+ export const decodeHtml = (str: string): string => {
16
+ return str.replace(
17
+ /&(\D+);/gi,
18
+ (tag) =>
19
+ ({
20
+ '&amp;': '&',
21
+ '&lt;': '<',
22
+ '&gt;': '>',
23
+ '&#39;': "'",
24
+ '&quot;': '"',
25
+ }[tag] || '')
26
+ );
27
+ };
@@ -0,0 +1,71 @@
1
+ import { ServerResponse } from 'http';
2
+ import { encodeHtml } from './encode-html';
3
+ import { IsType } from '../lib';
4
+
5
+ export const handler200 = (res: ServerResponse, msg: string | object, title = '', contentType?: string) => {
6
+ handlerResponse(res, 200, title, msg, contentType);
7
+ };
8
+
9
+ export const handler304 = (res: ServerResponse, msg?: string | object, contentType?: string) => {
10
+ handlerResponse(res, 304, '304 Not Modified', msg, contentType);
11
+ };
12
+
13
+ export const handler403 = (res: ServerResponse, msg?: string | object, contentType?: string) => {
14
+ handlerResponse(res, 403, '403 Forbidden', msg, contentType);
15
+ };
16
+
17
+ export const handler405 = (res: ServerResponse, msg?: string | object, contentType?: string) => {
18
+ handlerResponse(res, 405, '405 Method Not Allowed', msg, contentType);
19
+ };
20
+
21
+ export const handler404 = (res: ServerResponse, msg?: string | object, contentType?: string) => {
22
+ handlerResponse(res, 404, '404 Not Found', msg, contentType);
23
+ };
24
+
25
+ export const handler416 = (res: ServerResponse, msg?: string | object, contentType?: string) => {
26
+ handlerResponse(res, 416, '416 Range Not Satisfiable', msg, contentType);
27
+ };
28
+
29
+ export const handler500 = (res: ServerResponse, msg?: string | object, contentType?: string) => {
30
+ handlerResponse(res, 500, '500 Internal Server Error', msg, contentType);
31
+ };
32
+
33
+ export const handler400 = (res: ServerResponse, msg?: string | object, contentType?: string) => {
34
+ handlerResponse(res, 400, '400 Bad Request', msg, contentType);
35
+ };
36
+
37
+ export const handlerResponse = (
38
+ res: ServerResponse,
39
+ statusCode: number,
40
+ title: string,
41
+ msg?: string | object,
42
+ contentType?: string
43
+ ) => {
44
+ res.statusCode = statusCode;
45
+ if (res.writable) {
46
+ try {
47
+ res.setHeader('content-type', contentType || 'text/html');
48
+ } catch (e) {
49
+ // errors may have been triggered when headers being sent already
50
+ }
51
+ }
52
+
53
+ const text = IsType.isObject(msg) ? JSON.stringify(msg) : (msg as string);
54
+ const html = contentType === 'application/json' ? msg : generateHtml(title, text || title);
55
+ res.end(html);
56
+ };
57
+
58
+ export const generateHtml = (title: string, message: string) => {
59
+ return `${[
60
+ '<!doctype html>',
61
+ '<html>',
62
+ ' <head>',
63
+ ' <meta charset="utf-8">',
64
+ ` <title>${title}</title>`,
65
+ ' </head>',
66
+ ' <body>',
67
+ ` <p>${encodeHtml(message)}</p>`,
68
+ ' </body>',
69
+ '</html>',
70
+ ].join('\n')}\n`;
71
+ };
@@ -0,0 +1,16 @@
1
+ export * from './api-cache';
2
+ export * from './api-helper';
3
+ export * from './api-module';
4
+ export * from './api-router';
5
+ export * from './api-shared-storage';
6
+ export * from './async-storage';
7
+ export * from './debug-service';
8
+ export * from './encode-html';
9
+ export * from './handle-status';
10
+ export * from './mini-web-socket';
11
+ export * from './server-content-type';
12
+ export * from './server-render';
13
+ export * from './simple-storage';
14
+ export * from './static-server';
15
+ export * from './to-client-delivery';
16
+
@@ -0,0 +1,270 @@
1
+ import { IncomingMessage } from 'http';
2
+ import { Duplex } from 'stream';
3
+ import crypto from 'crypto';
4
+
5
+ // https://github.com/ErickWendel/websockets-with-nodejs-from-scratch/blob/main/nodejs-raw-websocket/server.mjs
6
+ // https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_servers
7
+ // https://github.com/websockets/ws/blob/d343a0cf7bba29a4e14217cb010446bec8fdf444/lib/receiver.js
8
+
9
+ // This is a specification specific to WebSocket protocol
10
+ const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
11
+
12
+ const MASK_KEY_BYTES_LENGTH = 4;
13
+ // opcodes
14
+ const OPCODE_CONTINUATION = 0x0;
15
+ const OPCODE_TEXT = 0x1;
16
+ const OPCODE_BINARY = 0x2;
17
+ const OPCODE_CLOSE = 0x8;
18
+ const OPCODE_PING = 0x9;
19
+ const OPCODE_PONG = 0xa;
20
+
21
+ // This is only used in debug mode (no clusters)
22
+ export type MiniWebSocketMsgProps = (msg: string, socket: Duplex) => void;
23
+ export type MiniWebSocketCloseProps = (socket: Duplex, status: string) => void;
24
+ export class MiniWebSocket {
25
+ clientRefreshFlag = Date.now();
26
+ clients = new Set<Duplex>();
27
+ private _onMessage: MiniWebSocketMsgProps;
28
+ private _onClose?: MiniWebSocketCloseProps;
29
+
30
+ constructor(onMessage: MiniWebSocketMsgProps, onClose?: MiniWebSocketCloseProps) {
31
+ this._onMessage = onMessage;
32
+ this._onClose = onClose;
33
+ }
34
+
35
+ public handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer) {
36
+ const key = req.headers['sec-websocket-key'];
37
+ const upgrade = (req.headers.upgrade || '').toString().toLowerCase();
38
+ const connection = (req.headers.connection || '').toString().toLowerCase();
39
+
40
+ if (!key || upgrade !== 'websocket' || !connection.includes('upgrade')) {
41
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
42
+ socket.destroy();
43
+ return;
44
+ }
45
+
46
+ const cleanup = (socket: Duplex, status: string) => {
47
+ if (this.clients.has(socket)) this.clients.delete(socket);
48
+ if (!socket.destroyed) socket.destroy();
49
+ if (this._onClose) this._onClose(socket, status);
50
+ };
51
+ this.clients.add(socket);
52
+ socket.on('close', () => cleanup(socket, 'close'));
53
+ socket.on('error', () => cleanup(socket, 'error'));
54
+ socket.on('end', () => cleanup(socket, 'end'));
55
+ socket.write(this.handleHeaders(key));
56
+
57
+ // buffer for cumulative data, parse frame; first append head (possible residual data)
58
+ let buffer = head && head.length ? Buffer.from(head) : Buffer.alloc(0);
59
+ const onData = (chunk: Buffer) => {
60
+ buffer = Buffer.concat([buffer, chunk]);
61
+ // try to parse as many complete frames as possible
62
+ parseFrames();
63
+ };
64
+
65
+ const parseFrames = () => {
66
+ while (true) {
67
+ if (buffer.length < 2) return;
68
+
69
+ const firstByte = buffer[0];
70
+ const secondByte = buffer[1];
71
+
72
+ const fin = (firstByte & 0x80) !== 0; // FIN bit
73
+ const rsv = firstByte & 0x70; // must be 0
74
+ const opcode = firstByte & 0x0f;
75
+
76
+ const masked = (secondByte & 0x80) !== 0; // must be 1
77
+ let payloadLen = secondByte & 0x7f;
78
+
79
+ let offset = 2;
80
+
81
+ // illegal case: RSV set bits; or client not mask
82
+ if (rsv !== 0 || !masked) {
83
+ this.sendClose(socket, 1002); // protocol error
84
+ cleanup(socket, 'protocol error');
85
+ return;
86
+ }
87
+
88
+ // only accept single frame (FIN=1) simple message, ignore fragmented/continuation frame
89
+ if (!fin && opcode !== OPCODE_CONTINUATION) {
90
+ this.sendClose(socket, 1003); // unsupported data
91
+ cleanup(socket, 'unsupported opcode');
92
+ return;
93
+ }
94
+
95
+ if (payloadLen === 126) {
96
+ if (buffer.length < offset + 2) return;
97
+ payloadLen = buffer.readUInt16BE(offset);
98
+ offset += 2;
99
+ } else if (payloadLen === 127) {
100
+ // read 64-bit length: we only take low 32 bits (enough for your scene)
101
+ if (buffer.length < offset + 8) return;
102
+ // ignore high 32
103
+ payloadLen = buffer.readUInt32BE(offset + 4);
104
+ offset += 8;
105
+ }
106
+
107
+ const needed = offset + MASK_KEY_BYTES_LENGTH + payloadLen;
108
+ if (buffer.length < needed) return;
109
+
110
+ const maskKey = buffer.slice(offset, offset + MASK_KEY_BYTES_LENGTH);
111
+ offset += MASK_KEY_BYTES_LENGTH;
112
+
113
+ let payload = buffer.slice(offset, offset + payloadLen);
114
+ // unmask
115
+ for (let i = 0; i < payload.length; i++) {
116
+ payload[i] ^= maskKey[i % MASK_KEY_BYTES_LENGTH];
117
+ }
118
+
119
+ // consume this frame
120
+ buffer = buffer.slice(needed);
121
+
122
+ switch (opcode) {
123
+ case OPCODE_TEXT: {
124
+ const text = payload.toString('utf8');
125
+ try {
126
+ this._onMessage(text, socket);
127
+ } catch {
128
+ // ignore user callback error
129
+ }
130
+ break;
131
+ }
132
+ case OPCODE_BINARY: {
133
+ // don't support binary, close
134
+ this.sendClose(socket, 1003); // unsupported data
135
+ cleanup(socket, 'unsupported binary');
136
+ return;
137
+ }
138
+ case OPCODE_PING: {
139
+ // reply Pong, with same payload
140
+ this.safeWrite(socket, this.encodeFrame(payload, OPCODE_PONG));
141
+ break;
142
+ }
143
+ case OPCODE_PONG: {
144
+ // ignore
145
+ break;
146
+ }
147
+ case OPCODE_CLOSE: {
148
+ // parse status code
149
+ let code = 1000;
150
+ if (payload.length >= 2) {
151
+ code = payload.readUInt16BE(0);
152
+ }
153
+ // reply Close, then end
154
+ this.sendClose(socket, code);
155
+ setTimeout(() => {
156
+ try {
157
+ socket.end();
158
+ } catch {}
159
+ cleanup(socket, 'close');
160
+ }, 20);
161
+ return; // end loop
162
+ }
163
+ case OPCODE_CONTINUATION: {
164
+ // don't support continuation, close
165
+ this.sendClose(socket, 1003);
166
+ cleanup(socket, 'unsupported continuation');
167
+ return;
168
+ }
169
+ default: {
170
+ // unknown opcode
171
+ this.sendClose(socket, 1002);
172
+ cleanup(socket, 'unknown opcode');
173
+ return;
174
+ }
175
+ }
176
+ }
177
+ };
178
+ socket.on('data', onData);
179
+ }
180
+
181
+ private handleHeaders(key: string) {
182
+ const acceptKey = crypto
183
+ .createHash('sha1')
184
+ .update(key + WEBSOCKET_GUID)
185
+ .digest('base64');
186
+ const headers = [
187
+ 'HTTP/1.1 101 Switching Protocols',
188
+ // 'Content-Type: text/html',
189
+ 'Upgrade: websocket',
190
+ 'Connection: Upgrade',
191
+ `Sec-WebSocket-Accept: ${acceptKey}`,
192
+ '\r\n',
193
+ ];
194
+ return headers.join('\r\n');
195
+ }
196
+
197
+ public broadcast(msg: string) {
198
+ const payload = Buffer.from(msg);
199
+ const frame = this.encodeFrame(payload, OPCODE_TEXT);
200
+ for (const socket of this.clients) {
201
+ this.safeWrite(socket, frame);
202
+ }
203
+ }
204
+ public sendMessage(socket: Duplex, msg: string) {
205
+ const payload = Buffer.from(msg);
206
+ this.safeWrite(socket, this.encodeFrame(payload, OPCODE_TEXT));
207
+ }
208
+
209
+ encodeFrame(payload: Buffer, opcode: number) {
210
+ const len = payload.length;
211
+
212
+ // 0x80 === 128 in binary
213
+ // FIN=1 + opcode
214
+ if (len <= 125) {
215
+ const header = Buffer.from([0x80 | opcode, len]);
216
+ return Buffer.concat([header, payload]);
217
+ }
218
+
219
+ if (len < 0x10000) {
220
+ // alloc 4 bytes
221
+ // [0] - 128 + 1 - 10000001 fin + opcode
222
+ // [1] - 126 + 0 - payload length marker + mask indicator
223
+ // [2] 0 - content length
224
+ // [3] 113 - content length
225
+ // [ 4 - ..] - the message itself
226
+ const header = Buffer.allocUnsafe(4);
227
+ header[0] = 0x80 | opcode;
228
+ header[1] = 126;
229
+ header.writeUInt16BE(len, 2);
230
+ return Buffer.concat([header, payload]);
231
+ }
232
+
233
+ // 64-bit length:write high 32 bits as 0, low 32 bits as len (enough for your scene)
234
+ const header = Buffer.allocUnsafe(10);
235
+ header[0] = 0x80 | opcode;
236
+ header[1] = 127;
237
+ header.writeUInt32BE(0, 2); // high 32
238
+ header.writeUInt32BE(len, 6); // low 32
239
+ return Buffer.concat([header, payload]);
240
+ }
241
+
242
+ sendClose(socket: Duplex, code = 1000) {
243
+ const payload = Buffer.allocUnsafe(2);
244
+ payload.writeUInt16BE(code, 0);
245
+ this.safeWrite(socket, this.encodeFrame(payload, OPCODE_CLOSE));
246
+ }
247
+
248
+ safeWrite(socket: Duplex, data: Buffer) {
249
+ try {
250
+ if (!socket.destroyed) socket.write(data);
251
+ } catch {
252
+ // ignore
253
+ }
254
+ }
255
+
256
+ close(socket: Duplex, code = 1000) {
257
+ this.sendClose(socket, code);
258
+ setTimeout(() => {
259
+ try {
260
+ socket.end();
261
+ } catch {}
262
+ }, 20);
263
+ }
264
+
265
+ closeAll(code = 1000) {
266
+ for (const s of this.clients) {
267
+ this.close(s, code);
268
+ }
269
+ }
270
+ }