@geode/opengeodeweb-front 10.36.0-rc.3 → 10.36.0-rc.5
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/.build/opengeodeweb_front_schemas.js +2 -1
- package/.build/server/utils/microservices.js +44 -2
- package/.build/server/utils/path.js +4 -1
- package/.build/server/utils/server_config.js +11 -1
- package/app/components/Viewer/ObjectTree/Base/ItemLabel.vue +4 -3
- package/app/composables/tree_filter.ts +6 -4
- package/app/stores/app_helpers/extension.ts +5 -1
- package/app/stores/data.ts +3 -0
- package/app/stores/data_helpers/collections.ts +1 -0
- package/app/stores/data_helpers/mesh.ts +2 -0
- package/app/stores/treeview.ts +9 -1
- package/app/utils/extension.ts +7 -2
- package/app/utils/import_workflow.ts +14 -2
- package/internal/database/tables/data.ts +2 -1
- package/opengeodeweb_front_schemas.json +3 -0
- package/opengeodeweb_front_typed_schemas.d.ts +1 -0
- package/package.json +1 -1
- package/server/api/local/extensions/run.post.ts +46 -27
- package/server/api/local/extensions/schemas/run.json +3 -0
- package/server/utils/extension.ts +5 -0
- package/server/utils/microservices.ts +53 -2
- package/server/utils/path.ts +4 -0
- package/server/utils/server_config.ts +14 -0
|
@@ -181,7 +181,8 @@ var api = {
|
|
|
181
181
|
"name": { "type": "string" },
|
|
182
182
|
"version": { "type": "string" },
|
|
183
183
|
"frontendContent": { "type": "string" },
|
|
184
|
-
"port": { "type": "integer" }
|
|
184
|
+
"port": { "type": "integer" },
|
|
185
|
+
"serverPort": { "type": "integer" }
|
|
185
186
|
},
|
|
186
187
|
"required": [
|
|
187
188
|
"id",
|
|
@@ -9,9 +9,10 @@ import child_process from "node:child_process";
|
|
|
9
9
|
import back_schemas from "@geode/opengeodeweb-back/opengeodeweb_back_schemas.json" with { type: "json" };
|
|
10
10
|
//#region server/utils/microservices.ts
|
|
11
11
|
const MILLISECONDS_PER_SECOND = 1e3;
|
|
12
|
-
const DEFAULT_TIMEOUT_SECONDS =
|
|
12
|
+
const DEFAULT_TIMEOUT_SECONDS = 120;
|
|
13
13
|
const MAX_PORT_RETRIES = 1;
|
|
14
14
|
const DEFAULT_RUN_ARGS = { projectFolderPath: "" };
|
|
15
|
+
const LISTENING_LOG_PATTERN = /^Listening on https?:\/\/.+:\d+\/?\s*$/mu;
|
|
15
16
|
async function runScript(execPath, execName, args, expectedResponse, timeoutSeconds = DEFAULT_TIMEOUT_SECONDS) {
|
|
16
17
|
const command = executablePath(execPath, execName);
|
|
17
18
|
consola.debug("runScript", command, args);
|
|
@@ -132,6 +133,47 @@ async function runExtension(extensionId, execName, execPath, args = DEFAULT_RUN_
|
|
|
132
133
|
}
|
|
133
134
|
throw new Error(`runExtension failed to run ${extensionId}`);
|
|
134
135
|
}
|
|
136
|
+
async function waitUntilListening(child) {
|
|
137
|
+
child.stderr.on("data", (data) => {
|
|
138
|
+
consola.info(`[${child.spawnfile}] STDERR:`, data.toString().trim());
|
|
139
|
+
});
|
|
140
|
+
child.on("close", (code) => {
|
|
141
|
+
consola.info(`[${child.spawnfile}] exited with code ${code}`);
|
|
142
|
+
});
|
|
143
|
+
for await (const chunk of child.stdout) {
|
|
144
|
+
const output = String(chunk);
|
|
145
|
+
consola.info(`[${child.spawnfile}] STDOUT:`, output.trim());
|
|
146
|
+
if (LISTENING_LOG_PATTERN.test(output)) {
|
|
147
|
+
child.stdout.on("data", (data) => {
|
|
148
|
+
consola.info(`[${child.spawnfile}] STDOUT:`, data.toString().trim());
|
|
149
|
+
});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
throw new Error(`${child.spawnfile} exited before becoming ready`);
|
|
154
|
+
}
|
|
155
|
+
async function runExtensionServer(entryPath, attempts = 0) {
|
|
156
|
+
let port = void 0;
|
|
157
|
+
try {
|
|
158
|
+
port = await getAvailablePort();
|
|
159
|
+
consola.info("runExtensionServer", entryPath, port);
|
|
160
|
+
await waitUntilListening(child_process.spawn("node", [entryPath], { env: {
|
|
161
|
+
...process.env,
|
|
162
|
+
PORT: String(port)
|
|
163
|
+
} }));
|
|
164
|
+
return port;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
if (!isPortInUseError(error)) {
|
|
167
|
+
consola.error("runExtensionServer error", error);
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
if (attempts <= MAX_PORT_RETRIES) {
|
|
171
|
+
consola.warn("Retrying runExtensionServer on conflicting port", port);
|
|
172
|
+
return await runExtensionServer(entryPath, attempts + 1);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`runExtensionServer failed to run ${entryPath}`);
|
|
176
|
+
}
|
|
135
177
|
function addMicroserviceMetadatas(projectFolderPath, serviceObj) {
|
|
136
178
|
const microservices = projectMicroservices(projectFolderPath);
|
|
137
179
|
let enriched = { ...serviceObj };
|
|
@@ -151,4 +193,4 @@ function addMicroserviceMetadatas(projectFolderPath, serviceObj) {
|
|
|
151
193
|
fs.writeFileSync(microservicesMetadatasPath(projectFolderPath), JSON.stringify({ microservices }, void 0, 2));
|
|
152
194
|
}
|
|
153
195
|
//#endregion
|
|
154
|
-
export { addMicroserviceMetadatas, runBack, runExtension, runViewer };
|
|
196
|
+
export { addMicroserviceMetadatas, runBack, runExtension, runExtensionServer, runViewer };
|
|
@@ -105,5 +105,8 @@ function extensionBackendPath(unzippedExtensionPath, backendExecutableName) {
|
|
|
105
105
|
consola.debug("runExtensions", { backendExecutablePath });
|
|
106
106
|
return backendExecutablePath;
|
|
107
107
|
}
|
|
108
|
+
function extensionServerEntryPath(unzippedExtensionPath, entry) {
|
|
109
|
+
return path.join(unzippedExtensionPath, entry);
|
|
110
|
+
}
|
|
108
111
|
//#endregion
|
|
109
|
-
export { createPath, executableName, executablePath, extensionBackendPath, extensionFolderPath, extensionFrontendPath, generateProjectFolderPath };
|
|
112
|
+
export { createPath, executableName, executablePath, extensionBackendPath, extensionFolderPath, extensionFrontendPath, extensionServerEntryPath, generateProjectFolderPath };
|
|
@@ -3,6 +3,7 @@ import { createServerWsRpcClient } from "./ws_client.js";
|
|
|
3
3
|
const stringStorage = /* @__PURE__ */ new Map();
|
|
4
4
|
const booleanStorage = /* @__PURE__ */ new Map();
|
|
5
5
|
const clientStorage = /* @__PURE__ */ new Map();
|
|
6
|
+
const extensionServerPorts = /* @__PURE__ */ new Map();
|
|
6
7
|
function getAppBaseUrl() {
|
|
7
8
|
const value = stringStorage.get("APP_BASE_URL");
|
|
8
9
|
if (value === void 0) throw new Error("APP_BASE_URL is not set");
|
|
@@ -47,5 +48,14 @@ async function getViewerWebSocketClient() {
|
|
|
47
48
|
if (viewerClient !== void 0 && viewerClient.isOpen()) return viewerClient;
|
|
48
49
|
return await setViewerWebSocketClient(getViewerBaseUrl());
|
|
49
50
|
}
|
|
51
|
+
function setExtensionServerPort(extensionId, port) {
|
|
52
|
+
extensionServerPorts.set(extensionId, port);
|
|
53
|
+
}
|
|
54
|
+
function getExtensionServerPort(extensionId) {
|
|
55
|
+
return extensionServerPorts.get(extensionId);
|
|
56
|
+
}
|
|
57
|
+
function getExtensionServerPorts() {
|
|
58
|
+
return extensionServerPorts;
|
|
59
|
+
}
|
|
50
60
|
//#endregion
|
|
51
|
-
export { getAppBaseUrl, getBackBaseUrl, getIsAppReady, getViewerBaseUrl, getViewerWebSocketClient, setAppBaseUrl, setBackBaseUrl, setIsAppReady, setViewerBaseUrl, setViewerWebSocketClient };
|
|
61
|
+
export { getAppBaseUrl, getBackBaseUrl, getExtensionServerPort, getExtensionServerPorts, getIsAppReady, getViewerBaseUrl, getViewerWebSocketClient, setAppBaseUrl, setBackBaseUrl, setExtensionServerPort, setIsAppReady, setViewerBaseUrl, setViewerWebSocketClient };
|
|
@@ -29,6 +29,7 @@ const { width: containerWidth } = useElementSize(labelContainer);
|
|
|
29
29
|
// The item prop can be either a DisplayItem wrapper (with a `.raw` domain object) or the domain object itself when this component is used outside CommonTreeView's slot machinery; the domain object always carries an id/title at runtime.
|
|
30
30
|
interface LabeledItem {
|
|
31
31
|
id: string;
|
|
32
|
+
geode_id?: string;
|
|
32
33
|
title?: string;
|
|
33
34
|
is_active?: boolean;
|
|
34
35
|
category?: string;
|
|
@@ -96,9 +97,9 @@ async function copyToClipboard(text: string, label: string): Promise<void> {
|
|
|
96
97
|
</template>
|
|
97
98
|
|
|
98
99
|
<div class="d-flex flex-column ga-1">
|
|
99
|
-
<span class="text-caption d-flex align-center">
|
|
100
|
+
<span v-if="actualItem.geode_id" class="text-caption d-flex align-center">
|
|
100
101
|
<strong class="text-white mr-1">ID:</strong>
|
|
101
|
-
<span data-testid="tooltipIdValue">{{ actualItem.
|
|
102
|
+
<span data-testid="tooltipIdValue">{{ actualItem.geode_id }}</span>
|
|
102
103
|
<v-btn
|
|
103
104
|
data-testid="copyIdBtn"
|
|
104
105
|
icon
|
|
@@ -106,7 +107,7 @@ async function copyToClipboard(text: string, label: string): Promise<void> {
|
|
|
106
107
|
density="compact"
|
|
107
108
|
class="ml-1 text-white"
|
|
108
109
|
style="width: 18px; height: 18px; min-width: 18px; min-height: 18px"
|
|
109
|
-
@click.stop="copyToClipboard(actualItem.
|
|
110
|
+
@click.stop="copyToClipboard(actualItem.geode_id, 'ID')"
|
|
110
111
|
>
|
|
111
112
|
<v-icon size="12">mdi-content-copy</v-icon>
|
|
112
113
|
</v-btn>
|
|
@@ -7,6 +7,8 @@ type ReadonlyMaybeRefOrGetter<Value> = Value | Ref<Value> | (() => Value);
|
|
|
7
7
|
interface FilterableItem {
|
|
8
8
|
readonly id: unknown;
|
|
9
9
|
readonly title?: string;
|
|
10
|
+
// Present on geode objects (data and model components), absent on group nodes.
|
|
11
|
+
readonly geode_id?: string;
|
|
10
12
|
readonly children?: readonly FilterableItem[];
|
|
11
13
|
}
|
|
12
14
|
|
|
@@ -46,15 +48,15 @@ function customFilter(
|
|
|
46
48
|
return false;
|
|
47
49
|
}
|
|
48
50
|
const query = searchQuery.toLowerCase();
|
|
49
|
-
const { title = "", id = value } = item.raw;
|
|
50
|
-
return [title, id].some((field) => String(field).toLowerCase().includes(query));
|
|
51
|
+
const { title = "", id = value, geode_id: geodeId } = item.raw;
|
|
52
|
+
return [title, id, geodeId].some((field) => String(field).toLowerCase().includes(query));
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
// Extracted so we never call String() directly on a value typed as `unknown`/`any`.
|
|
54
56
|
// The `id` field can be anything at runtime, so only stringify primitives that have a sane toString.
|
|
55
57
|
// Anything else falls back to "" instead of risking "[object Object]".
|
|
56
58
|
function toSortKey(item: FilterableItem, field: "title" | "id"): string {
|
|
57
|
-
const raw =
|
|
59
|
+
const raw = (field === "id" ? item.geode_id : item.title) ?? item.id ?? "";
|
|
58
60
|
if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
|
|
59
61
|
return String(raw);
|
|
60
62
|
}
|
|
@@ -136,7 +138,7 @@ function useTreeFilter(
|
|
|
136
138
|
const children = (category.children ?? []).filter((child) =>
|
|
137
139
|
customFilter(child.id, search.value, { raw: child }),
|
|
138
140
|
);
|
|
139
|
-
if (children.length > 0
|
|
141
|
+
if (children.length > 0) {
|
|
140
142
|
result.push({ ...category, children });
|
|
141
143
|
}
|
|
142
144
|
}
|
|
@@ -59,6 +59,7 @@ export function useAppExtensions(): {
|
|
|
59
59
|
async function loadExtension(
|
|
60
60
|
path: string,
|
|
61
61
|
extensionPort: string,
|
|
62
|
+
serverPort?: string,
|
|
62
63
|
backendPath?: string,
|
|
63
64
|
): Promise<ExtensionModule> {
|
|
64
65
|
try {
|
|
@@ -77,7 +78,10 @@ export function useAppExtensions(): {
|
|
|
77
78
|
// oxlint-disable-next-line no-inline-comments, no-unsafe-assignment
|
|
78
79
|
const extensionModule: ExtensionModule = await import(/* @vite-ignore */ finalURL);
|
|
79
80
|
const store = extensionModule.metadata.store();
|
|
80
|
-
store.$patch?.({
|
|
81
|
+
store.$patch?.({
|
|
82
|
+
default_local_port: extensionPort,
|
|
83
|
+
...(serverPort === undefined ? {} : { nitro_port: serverPort }),
|
|
84
|
+
});
|
|
81
85
|
|
|
82
86
|
if (finalURL !== path && finalURL.startsWith("blob:")) {
|
|
83
87
|
URL.revokeObjectURL(finalURL);
|
package/app/stores/data.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { useViewerStore } from "@ogw_front/stores/viewer";
|
|
|
13
13
|
|
|
14
14
|
interface DataItem {
|
|
15
15
|
id: string;
|
|
16
|
+
geode_id: string;
|
|
16
17
|
name: string;
|
|
17
18
|
viewer_type: string;
|
|
18
19
|
geode_object_type: string;
|
|
@@ -35,6 +36,7 @@ interface ModelComponentInput {
|
|
|
35
36
|
|
|
36
37
|
interface NewDataItem {
|
|
37
38
|
id: string;
|
|
39
|
+
geode_id: string;
|
|
38
40
|
name?: string;
|
|
39
41
|
viewer_type: string;
|
|
40
42
|
geode_object_type: string;
|
|
@@ -199,6 +201,7 @@ export const useDataStore = defineStore("data", () => {
|
|
|
199
201
|
async function addItem(new_item: NewDataItem): Promise<string> {
|
|
200
202
|
const itemData: DataItem = {
|
|
201
203
|
id: new_item.id,
|
|
204
|
+
geode_id: new_item.geode_id,
|
|
202
205
|
name: new_item.name ?? new_item.id,
|
|
203
206
|
viewer_type: new_item.viewer_type,
|
|
204
207
|
geode_object_type: new_item.geode_object_type,
|
|
@@ -59,6 +59,7 @@ export function useDataCollections(): {
|
|
|
59
59
|
.filter((component: ModelComponentRecord) => !MESH_COMPONENT_TYPES.includes(component.type))
|
|
60
60
|
.map((component: ModelComponentRecord) => ({
|
|
61
61
|
id: component.geode_id,
|
|
62
|
+
geode_id: component.geode_id,
|
|
62
63
|
title: component.name,
|
|
63
64
|
category: component.type,
|
|
64
65
|
viewer_id: Number(component.viewer_id),
|
|
@@ -14,6 +14,7 @@ interface ModelComponentRecord {
|
|
|
14
14
|
|
|
15
15
|
interface FormattedComponent {
|
|
16
16
|
readonly id: string;
|
|
17
|
+
readonly geode_id: string;
|
|
17
18
|
readonly title: string;
|
|
18
19
|
readonly category: string;
|
|
19
20
|
readonly viewer_id: number;
|
|
@@ -29,6 +30,7 @@ interface FormattedComponentGroup {
|
|
|
29
30
|
function toFormattedComponent(meshComponent: ModelComponentRecord): FormattedComponent {
|
|
30
31
|
return {
|
|
31
32
|
id: meshComponent.geode_id,
|
|
33
|
+
geode_id: meshComponent.geode_id,
|
|
32
34
|
title: meshComponent.name,
|
|
33
35
|
category: meshComponent.type,
|
|
34
36
|
viewer_id: Math.trunc(Number(meshComponent.viewer_id)),
|
package/app/stores/treeview.ts
CHANGED
|
@@ -25,6 +25,7 @@ interface OpenedView {
|
|
|
25
25
|
interface TreeviewChild {
|
|
26
26
|
title: string;
|
|
27
27
|
id: string;
|
|
28
|
+
geode_id: string;
|
|
28
29
|
viewer_type: string;
|
|
29
30
|
geode_object_type: string;
|
|
30
31
|
}
|
|
@@ -170,10 +171,17 @@ export const useTreeviewStore = defineStore("treeview", () => {
|
|
|
170
171
|
}
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
function addItem(
|
|
174
|
+
function addItem(
|
|
175
|
+
geodeObjectType: string,
|
|
176
|
+
name: string,
|
|
177
|
+
id: string,
|
|
178
|
+
geode_id: string,
|
|
179
|
+
viewer_type: string,
|
|
180
|
+
): void {
|
|
174
181
|
const child: TreeviewChild = {
|
|
175
182
|
title: name,
|
|
176
183
|
id,
|
|
184
|
+
geode_id,
|
|
177
185
|
viewer_type,
|
|
178
186
|
geode_object_type: geodeObjectType,
|
|
179
187
|
};
|
package/app/utils/extension.ts
CHANGED
|
@@ -15,6 +15,7 @@ interface ExtensionDescriptor {
|
|
|
15
15
|
version: string;
|
|
16
16
|
frontendContent: string;
|
|
17
17
|
port: number;
|
|
18
|
+
serverPort?: number;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
interface DownloadExtensionParams {
|
|
@@ -87,12 +88,16 @@ async function registerRunningExtensions(): Promise<RegisteredExtension[]> {
|
|
|
87
88
|
const { extensionsArray } = await runExtensions();
|
|
88
89
|
return Promise.all(
|
|
89
90
|
extensionsArray.map(async (extension: ExtensionDescriptor) => {
|
|
90
|
-
const { id, name, version, frontendContent, port } = extension;
|
|
91
|
+
const { id, name, version, frontendContent, port, serverPort } = extension;
|
|
91
92
|
const blob = new Blob([frontendContent], {
|
|
92
93
|
type: "application/javascript",
|
|
93
94
|
});
|
|
94
95
|
const blobUrl = URL.createObjectURL(blob);
|
|
95
|
-
const extensionModule = await appStore.loadExtension(
|
|
96
|
+
const extensionModule = await appStore.loadExtension(
|
|
97
|
+
blobUrl,
|
|
98
|
+
String(port),
|
|
99
|
+
serverPort === undefined ? undefined : String(serverPort),
|
|
100
|
+
);
|
|
96
101
|
consola.info("[ExtensionManager] Extension loaded:", id);
|
|
97
102
|
const storeFactory = extensionModule.metadata.store;
|
|
98
103
|
const store = storeFactory();
|
|
@@ -38,7 +38,13 @@ async function importItem(item: NewDataItem): Promise<string> {
|
|
|
38
38
|
item.viewer_type === "model" ? dataStore.addComponents(item) : Promise.resolve();
|
|
39
39
|
const addDataRelationsTask =
|
|
40
40
|
item.viewer_type === "model" ? dataStore.addComponentRelations(item) : Promise.resolve();
|
|
41
|
-
treeviewStore.addItem(
|
|
41
|
+
treeviewStore.addItem(
|
|
42
|
+
item.geode_object_type,
|
|
43
|
+
item.name ?? item.id,
|
|
44
|
+
item.id,
|
|
45
|
+
item.geode_id,
|
|
46
|
+
item.viewer_type,
|
|
47
|
+
);
|
|
42
48
|
const addDataStyleTask = dataStyleStore.addDataStyle(item.id, item.geode_object_type);
|
|
43
49
|
const addViewerTask = addDataTask.then(async () => {
|
|
44
50
|
if (!(await dataStore.isItemViewable(item))) {
|
|
@@ -80,6 +86,9 @@ async function importFile(filename: string, geode_object_type: string): Promise<
|
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
async function importWorkflow(files: readonly FileToImport[]): Promise<string[]> {
|
|
89
|
+
const hybridViewerStore = useHybridViewerStore();
|
|
90
|
+
// Files are imported concurrently and the viewer only frames the first actor to arrive, so the camera is reset once everything is loaded
|
|
91
|
+
const wasSceneEmpty = Object.keys(hybridViewerStore.hybridDb).length === 0;
|
|
83
92
|
const chunk_size = 5;
|
|
84
93
|
const chunks: FileToImport[][] = [];
|
|
85
94
|
for (let i = 0; i < files.length; i += chunk_size) {
|
|
@@ -102,7 +111,10 @@ async function importWorkflow(files: readonly FileToImport[]): Promise<string[]>
|
|
|
102
111
|
await processChunk(chunkIndex + 1);
|
|
103
112
|
}
|
|
104
113
|
await processChunk(0);
|
|
105
|
-
const
|
|
114
|
+
const isSceneFilled = Object.keys(hybridViewerStore.hybridDb).length > 0;
|
|
115
|
+
if (wasSceneEmpty && isSceneFilled) {
|
|
116
|
+
hybridViewerStore.resetCamera();
|
|
117
|
+
}
|
|
106
118
|
await hybridViewerStore.remoteRender();
|
|
107
119
|
return results;
|
|
108
120
|
}
|
|
@@ -2,5 +2,6 @@ import type { TableDefinition } from "./table_definition";
|
|
|
2
2
|
|
|
3
3
|
export const dataTable: TableDefinition = {
|
|
4
4
|
name: "data",
|
|
5
|
-
schema:
|
|
5
|
+
schema:
|
|
6
|
+
"id, geode_id, name, viewer_type, geode_object_type, visible, created_at, binary_light_viewable",
|
|
6
7
|
};
|
package/package.json
CHANGED
|
@@ -7,54 +7,73 @@ import fs from "node:fs";
|
|
|
7
7
|
import {
|
|
8
8
|
addMicroserviceMetadatas,
|
|
9
9
|
runBack,
|
|
10
|
+
runExtensionServer,
|
|
10
11
|
} from "@geode/opengeodeweb-front/server/utils/microservices.ts";
|
|
11
12
|
import {
|
|
12
13
|
extensionBackendPath,
|
|
13
14
|
extensionFolderPath,
|
|
15
|
+
extensionServerEntryPath,
|
|
14
16
|
} from "@geode/opengeodeweb-front/server/utils/path.ts";
|
|
15
17
|
import {
|
|
16
18
|
readExtensionFrontend,
|
|
17
19
|
readExtensionMetadata,
|
|
18
20
|
} from "@geode/opengeodeweb-front/server/utils/extension.ts";
|
|
19
21
|
import { extensionsConf } from "@geode/opengeodeweb-front/server/utils/app_config.ts";
|
|
22
|
+
import { setExtensionServerPort } from "@geode/opengeodeweb-front/server/utils/server_config.ts";
|
|
20
23
|
import { unzipFile } from "@geode/opengeodeweb-front/server/utils/server.ts";
|
|
21
24
|
|
|
22
25
|
import { defineTypedEventHandler } from "@geode/opengeodeweb-front/server/utils/typed_handler.ts";
|
|
23
26
|
import schemas from "@geode/opengeodeweb-front/opengeodeweb_front_typed_schemas.js";
|
|
24
27
|
|
|
28
|
+
async function runSingleExtension(
|
|
29
|
+
extensionPath: string,
|
|
30
|
+
extensionId: string,
|
|
31
|
+
projectFolderPath: string,
|
|
32
|
+
): Promise<{
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
version: string;
|
|
36
|
+
frontendContent: string;
|
|
37
|
+
port: number;
|
|
38
|
+
serverPort: number | undefined;
|
|
39
|
+
}> {
|
|
40
|
+
const unzippedExtensionPath = await unzipFile(
|
|
41
|
+
extensionPath,
|
|
42
|
+
extensionFolderPath(projectFolderPath, extensionId),
|
|
43
|
+
);
|
|
44
|
+
const { id, name, version, backendExecutable, server, frontendFile } =
|
|
45
|
+
await readExtensionMetadata(unzippedExtensionPath);
|
|
46
|
+
const frontendContent = await readExtensionFrontend(unzippedExtensionPath, frontendFile, id);
|
|
47
|
+
|
|
48
|
+
fs.chmodSync(extensionBackendPath(unzippedExtensionPath, backendExecutable), "755");
|
|
49
|
+
const port = await runBack(backendExecutable, unzippedExtensionPath, {
|
|
50
|
+
projectFolderPath,
|
|
51
|
+
});
|
|
52
|
+
addMicroserviceMetadatas(projectFolderPath, {
|
|
53
|
+
type: "back",
|
|
54
|
+
name,
|
|
55
|
+
port,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const serverPort =
|
|
59
|
+
server === undefined
|
|
60
|
+
? undefined
|
|
61
|
+
: await runExtensionServer(extensionServerEntryPath(unzippedExtensionPath, server.entry));
|
|
62
|
+
if (serverPort !== undefined) {
|
|
63
|
+
setExtensionServerPort(id, serverPort);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { id, name, version, frontendContent, port, serverPort };
|
|
67
|
+
}
|
|
68
|
+
|
|
25
69
|
export default defineTypedEventHandler(
|
|
26
70
|
schemas.api.local.extensions.run,
|
|
27
71
|
async ({ projectFolderPath, projectName }) => {
|
|
28
72
|
const extensionsConfig = extensionsConf(projectName);
|
|
29
73
|
const extensionsArray = await Promise.all(
|
|
30
74
|
Object.entries(extensionsConfig).map(async ([extensionId, { path: extensionPath }]) => {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
extensionFolderPath(projectFolderPath, extensionId),
|
|
34
|
-
);
|
|
35
|
-
const { id, name, version, backendExecutable, frontendFile } =
|
|
36
|
-
await readExtensionMetadata(unzippedExtensionPath);
|
|
37
|
-
const frontendContent = await readExtensionFrontend(
|
|
38
|
-
unzippedExtensionPath,
|
|
39
|
-
frontendFile,
|
|
40
|
-
id,
|
|
41
|
-
);
|
|
42
|
-
fs.chmodSync(extensionBackendPath(unzippedExtensionPath, backendExecutable), "755");
|
|
43
|
-
const port = await runBack(backendExecutable, unzippedExtensionPath, {
|
|
44
|
-
projectFolderPath,
|
|
45
|
-
});
|
|
46
|
-
addMicroserviceMetadatas(projectFolderPath, {
|
|
47
|
-
type: "back",
|
|
48
|
-
name,
|
|
49
|
-
port,
|
|
50
|
-
});
|
|
51
|
-
return {
|
|
52
|
-
id,
|
|
53
|
-
name,
|
|
54
|
-
version,
|
|
55
|
-
frontendContent,
|
|
56
|
-
port,
|
|
57
|
-
};
|
|
75
|
+
const result = await runSingleExtension(extensionPath, extensionId, projectFolderPath);
|
|
76
|
+
return result;
|
|
58
77
|
}),
|
|
59
78
|
);
|
|
60
79
|
|
|
@@ -10,6 +10,10 @@ import { createError } from "h3";
|
|
|
10
10
|
// Local imports
|
|
11
11
|
import { extensionFrontendPath } from "@geode/opengeodeweb-front/server/utils/path.ts";
|
|
12
12
|
|
|
13
|
+
const extensionServerMetadataSchema = z.object({
|
|
14
|
+
entry: z.string(),
|
|
15
|
+
});
|
|
16
|
+
|
|
13
17
|
const extensionMetadataSchema = z
|
|
14
18
|
.object({
|
|
15
19
|
id: z.string(),
|
|
@@ -17,6 +21,7 @@ const extensionMetadataSchema = z
|
|
|
17
21
|
version: z.string(),
|
|
18
22
|
frontendFile: z.string(),
|
|
19
23
|
backendExecutable: z.string(),
|
|
24
|
+
server: extensionServerMetadataSchema.optional(),
|
|
20
25
|
})
|
|
21
26
|
.loose();
|
|
22
27
|
|
|
@@ -20,9 +20,10 @@ interface RunArgs {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const MILLISECONDS_PER_SECOND = 1000;
|
|
23
|
-
const DEFAULT_TIMEOUT_SECONDS =
|
|
23
|
+
const DEFAULT_TIMEOUT_SECONDS = 120;
|
|
24
24
|
const MAX_PORT_RETRIES = 1;
|
|
25
25
|
const DEFAULT_RUN_ARGS: RunArgs = { projectFolderPath: "" };
|
|
26
|
+
const LISTENING_LOG_PATTERN = /^Listening on https?:\/\/.+:\d+\/?\s*$/mu;
|
|
26
27
|
|
|
27
28
|
async function runScript(
|
|
28
29
|
execPath: string,
|
|
@@ -183,6 +184,56 @@ async function runExtension(
|
|
|
183
184
|
}
|
|
184
185
|
throw new Error(`runExtension failed to run ${extensionId}`);
|
|
185
186
|
}
|
|
187
|
+
async function waitUntilListening(
|
|
188
|
+
child: child_process.ChildProcessWithoutNullStreams,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
child.stderr.on("data", (data: Buffer) => {
|
|
191
|
+
consola.info(`[${child.spawnfile}] STDERR:`, data.toString().trim());
|
|
192
|
+
});
|
|
193
|
+
child.on("close", (code) => {
|
|
194
|
+
consola.info(`[${child.spawnfile}] exited with code ${code}`);
|
|
195
|
+
});
|
|
196
|
+
// oxlint-disable-next-line typescript/no-unnecessary-condition
|
|
197
|
+
for await (const chunk of child.stdout) {
|
|
198
|
+
const output = String(chunk);
|
|
199
|
+
consola.info(`[${child.spawnfile}] STDOUT:`, output.trim());
|
|
200
|
+
if (LISTENING_LOG_PATTERN.test(output)) {
|
|
201
|
+
child.stdout.on("data", (data: Buffer) => {
|
|
202
|
+
consola.info(`[${child.spawnfile}] STDOUT:`, data.toString().trim());
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
throw new Error(`${child.spawnfile} exited before becoming ready`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function runExtensionServer(entryPath: string, attempts = 0): Promise<number> {
|
|
211
|
+
let port: number | undefined = undefined;
|
|
212
|
+
try {
|
|
213
|
+
port = await getAvailablePort();
|
|
214
|
+
consola.info("runExtensionServer", entryPath, port);
|
|
215
|
+
const child = child_process.spawn("node", [entryPath], {
|
|
216
|
+
env: {
|
|
217
|
+
...process.env,
|
|
218
|
+
PORT: String(port),
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
await waitUntilListening(child);
|
|
222
|
+
return port;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (!isPortInUseError(error)) {
|
|
225
|
+
consola.error("runExtensionServer error", error);
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
if (attempts <= MAX_PORT_RETRIES) {
|
|
229
|
+
consola.warn("Retrying runExtensionServer on conflicting port", port);
|
|
230
|
+
const newPort = await runExtensionServer(entryPath, attempts + 1);
|
|
231
|
+
return newPort;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
throw new Error(`runExtensionServer failed to run ${entryPath}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
186
237
|
function addMicroserviceMetadatas(projectFolderPath: string, serviceObj: Microservice): void {
|
|
187
238
|
const microservices = projectMicroservices(projectFolderPath);
|
|
188
239
|
let enriched: Microservice = { ...serviceObj };
|
|
@@ -213,4 +264,4 @@ function addMicroserviceMetadatas(projectFolderPath: string, serviceObj: Microse
|
|
|
213
264
|
);
|
|
214
265
|
}
|
|
215
266
|
|
|
216
|
-
export { addMicroserviceMetadatas, runBack, runExtension, runViewer };
|
|
267
|
+
export { addMicroserviceMetadatas, runBack, runExtension, runExtensionServer, runViewer };
|
package/server/utils/path.ts
CHANGED
|
@@ -163,11 +163,15 @@ function extensionBackendPath(
|
|
|
163
163
|
});
|
|
164
164
|
return backendExecutablePath;
|
|
165
165
|
}
|
|
166
|
+
function extensionServerEntryPath(unzippedExtensionPath: string, entry: string): string {
|
|
167
|
+
return path.join(unzippedExtensionPath, entry);
|
|
168
|
+
}
|
|
166
169
|
export {
|
|
167
170
|
createPath,
|
|
168
171
|
extensionBackendPath,
|
|
169
172
|
extensionFrontendPath,
|
|
170
173
|
extensionFolderPath,
|
|
174
|
+
extensionServerEntryPath,
|
|
171
175
|
executablePath,
|
|
172
176
|
executableName,
|
|
173
177
|
generateProjectFolderPath,
|
|
@@ -3,6 +3,7 @@ import { type ServerWsRpcClient, createServerWsRpcClient } from "./ws_client.js"
|
|
|
3
3
|
const stringStorage = new Map<string, string>();
|
|
4
4
|
const booleanStorage = new Map<string, boolean>();
|
|
5
5
|
const clientStorage = new Map<string, ServerWsRpcClient>();
|
|
6
|
+
const extensionServerPorts = new Map<string, number>();
|
|
6
7
|
|
|
7
8
|
function getAppBaseUrl(): string {
|
|
8
9
|
const value = stringStorage.get("APP_BASE_URL");
|
|
@@ -60,14 +61,27 @@ async function getViewerWebSocketClient(): Promise<ServerWsRpcClient> {
|
|
|
60
61
|
const client = await setViewerWebSocketClient(viewerBaseUrl);
|
|
61
62
|
return client;
|
|
62
63
|
}
|
|
64
|
+
function setExtensionServerPort(extensionId: string, port: number): void {
|
|
65
|
+
extensionServerPorts.set(extensionId, port);
|
|
66
|
+
}
|
|
67
|
+
function getExtensionServerPort(extensionId: string): number | undefined {
|
|
68
|
+
return extensionServerPorts.get(extensionId);
|
|
69
|
+
}
|
|
70
|
+
function getExtensionServerPorts(): ReadonlyMap<string, number> {
|
|
71
|
+
return extensionServerPorts;
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
export {
|
|
64
75
|
getAppBaseUrl,
|
|
65
76
|
getBackBaseUrl,
|
|
77
|
+
getExtensionServerPort,
|
|
78
|
+
getExtensionServerPorts,
|
|
66
79
|
getIsAppReady,
|
|
67
80
|
getViewerBaseUrl,
|
|
68
81
|
getViewerWebSocketClient,
|
|
69
82
|
setAppBaseUrl,
|
|
70
83
|
setBackBaseUrl,
|
|
84
|
+
setExtensionServerPort,
|
|
71
85
|
setIsAppReady,
|
|
72
86
|
setViewerBaseUrl,
|
|
73
87
|
setViewerWebSocketClient,
|