openlearn-next 0.3.10 → 0.3.13
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/assets/{BatchPickerModal-BhIFzqMZ.js → BatchPickerModal-Cz10EQIx.js} +1 -1
- package/dist/assets/{ClassesView-BWzkFR-C.js → ClassesView-B5mQUqdu.js} +1 -1
- package/dist/assets/{CloudDriveModal-Dy_toaqC.js → CloudDriveModal-DVgJpq73.js} +1 -1
- package/dist/assets/{CourseManagement-DTIt-D1_.js → CourseManagement-CfyqBe9O.js} +1 -1
- package/dist/assets/{CourseWizardModal-DEx5wbVt.js → CourseWizardModal-DgGqaOBb.js} +1 -1
- package/dist/assets/{Dashboard-DrD2ZUG2.js → Dashboard-CVLDc2Ra.js} +1 -1
- package/dist/assets/{ExportWeightModal-D3bGjA61.js → ExportWeightModal-BPzBJ5QA.js} +1 -1
- package/dist/assets/{HelpView-DOQ_FtPQ.js → HelpView-D7FDc6f2.js} +1 -1
- package/dist/assets/{ImportLessonsModal-R3T0dy5k.js → ImportLessonsModal-CsNg6pUL.js} +1 -1
- package/dist/assets/{InteractiveCoursewareViewer-CL1yz-wN.js → InteractiveCoursewareViewer-CWBrLReA.js} +1 -1
- package/dist/assets/{InteractiveWhiteboard-Cua0Ksff.js → InteractiveWhiteboard-Jjkl4Ayz.js} +1 -1
- package/dist/assets/{LazyCourseware-CjwoXy7i.js → LazyCourseware-C1Iw9I5f.js} +1 -1
- package/dist/assets/{LazyWhiteboard-C1kc33rY.js → LazyWhiteboard-DumYte7H.js} +1 -1
- package/dist/assets/{LessonEditorView-K0YB64km.js → LessonEditorView-CheUoLYL.js} +1 -1
- package/dist/assets/{LiveClassroomView-D4IQGl2d.js → LiveClassroomView-tKU_lHcI.js} +1 -1
- package/dist/assets/{NotificationDetailModal-C7n-UNti.js → NotificationDetailModal-uccOU2pe.js} +1 -1
- package/dist/assets/{PluginView-4aUL9Ma3.js → PluginView-M7vQw4BM.js} +1 -1
- package/dist/assets/{QuizGeneratorModal-Cwv80A2_.js → QuizGeneratorModal-BuNlxGoR.js} +1 -1
- package/dist/assets/{StudentAssignmentView-DTFVSpw7.js → StudentAssignmentView-DLtHxJi9.js} +1 -1
- package/dist/assets/{StudentLessonView-CETCftTC.js → StudentLessonView-D0Ad4Xlk.js} +1 -1
- package/dist/assets/{StudentPreviewModal-CIQx5-dG.js → StudentPreviewModal-hjJu-Rno.js} +1 -1
- package/dist/assets/{StudentView-DtWzM17Q.js → StudentView-BcNgjfKD.js} +1 -1
- package/dist/assets/{SystemResourceLibraryModal-Bv3wwXDH.js → SystemResourceLibraryModal-BFX8Od8z.js} +1 -1
- package/dist/assets/{TeacherView-BEXx-Vwl.js → TeacherView-B4YDVXJu.js} +1 -1
- package/dist/assets/{index-PM-cSFwO.js → index-WzkWF1BD.js} +1 -1
- package/dist/assets/{index-kqgYec9C.js → index-hKvFOZ9V.js} +3 -3
- package/dist/index.html +1 -1
- package/dist/server.cjs +1209 -6
- package/package.json +17 -17
package/dist/server.cjs
CHANGED
|
@@ -805,6 +805,207 @@ var init_config_service = __esm({
|
|
|
805
805
|
}
|
|
806
806
|
});
|
|
807
807
|
|
|
808
|
+
// packages/core/plugin-host/http-router.ts
|
|
809
|
+
function compileRoutePattern(pattern) {
|
|
810
|
+
const normalized = pattern.startsWith("/") ? pattern : "/" + pattern;
|
|
811
|
+
const paramNames = [];
|
|
812
|
+
const regexStr = normalized.replace(/:([a-zA-Z0-9_]+)/g, (_match, paramName) => {
|
|
813
|
+
paramNames.push(paramName);
|
|
814
|
+
return "([^/]+)";
|
|
815
|
+
}).replace(/\*/g, () => {
|
|
816
|
+
paramNames.push("wildcard");
|
|
817
|
+
return "(.*)";
|
|
818
|
+
});
|
|
819
|
+
return {
|
|
820
|
+
regex: new RegExp(`^${regexStr}$`),
|
|
821
|
+
paramNames
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
var PluginHttpRouter;
|
|
825
|
+
var init_http_router = __esm({
|
|
826
|
+
"packages/core/plugin-host/http-router.ts"() {
|
|
827
|
+
PluginHttpRouter = class {
|
|
828
|
+
constructor() {
|
|
829
|
+
this.routes = [];
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* 注册指定动词与路径的 Handler
|
|
833
|
+
*/
|
|
834
|
+
route(method, path20, handler) {
|
|
835
|
+
const upperMethod = method.toUpperCase();
|
|
836
|
+
const normalized = path20.startsWith("/") ? path20 : "/" + path20;
|
|
837
|
+
const { regex, paramNames } = compileRoutePattern(normalized);
|
|
838
|
+
this.routes.push({
|
|
839
|
+
method: upperMethod,
|
|
840
|
+
pattern: normalized,
|
|
841
|
+
regex,
|
|
842
|
+
paramNames,
|
|
843
|
+
handler,
|
|
844
|
+
isStream: false
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
get(path20, handler) {
|
|
848
|
+
this.route("GET", path20, handler);
|
|
849
|
+
}
|
|
850
|
+
post(path20, handler) {
|
|
851
|
+
this.route("POST", path20, handler);
|
|
852
|
+
}
|
|
853
|
+
put(path20, handler) {
|
|
854
|
+
this.route("PUT", path20, handler);
|
|
855
|
+
}
|
|
856
|
+
patch(path20, handler) {
|
|
857
|
+
this.route("PATCH", path20, handler);
|
|
858
|
+
}
|
|
859
|
+
delete(path20, handler) {
|
|
860
|
+
this.route("DELETE", path20, handler);
|
|
861
|
+
}
|
|
862
|
+
stream(methodOrPath, pathOrHandler, maybeHandler) {
|
|
863
|
+
if (typeof pathOrHandler === "function") {
|
|
864
|
+
const path20 = methodOrPath;
|
|
865
|
+
const handler = pathOrHandler;
|
|
866
|
+
this.registerStream("GET", path20, handler);
|
|
867
|
+
this.registerStream("POST", path20, handler);
|
|
868
|
+
} else {
|
|
869
|
+
const method = methodOrPath.toUpperCase();
|
|
870
|
+
const path20 = pathOrHandler;
|
|
871
|
+
const handler = maybeHandler;
|
|
872
|
+
this.registerStream(method, path20, handler);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
registerStream(method, path20, handler) {
|
|
876
|
+
const normalized = path20.startsWith("/") ? path20 : "/" + path20;
|
|
877
|
+
const { regex, paramNames } = compileRoutePattern(normalized);
|
|
878
|
+
this.routes.push({
|
|
879
|
+
method: method.toUpperCase(),
|
|
880
|
+
pattern: normalized,
|
|
881
|
+
regex,
|
|
882
|
+
paramNames,
|
|
883
|
+
streamHandler: handler,
|
|
884
|
+
isStream: true
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* 检查指定动词与路径是否为流式路由
|
|
889
|
+
*/
|
|
890
|
+
isStream(method, path20) {
|
|
891
|
+
const upperMethod = method.toUpperCase();
|
|
892
|
+
const normalized = path20.startsWith("/") ? path20 : "/" + path20;
|
|
893
|
+
for (const entry of this.routes) {
|
|
894
|
+
if (entry.method !== upperMethod) continue;
|
|
895
|
+
if (entry.regex.test(normalized)) {
|
|
896
|
+
return entry.isStream === true;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* 匹配路由并提取路由参数
|
|
903
|
+
*/
|
|
904
|
+
match(method, path20) {
|
|
905
|
+
const upperMethod = method.toUpperCase();
|
|
906
|
+
const normalized = path20.startsWith("/") ? path20 : "/" + path20;
|
|
907
|
+
for (const entry of this.routes) {
|
|
908
|
+
if (entry.method !== upperMethod) continue;
|
|
909
|
+
const match = normalized.match(entry.regex);
|
|
910
|
+
if (match) {
|
|
911
|
+
const params = {};
|
|
912
|
+
for (let i = 0; i < entry.paramNames.length; i++) {
|
|
913
|
+
params[entry.paramNames[i]] = decodeURIComponent(match[i + 1] || "");
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
handler: entry.handler,
|
|
917
|
+
streamHandler: entry.streamHandler,
|
|
918
|
+
isStream: entry.isStream,
|
|
919
|
+
params
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* 执行请求
|
|
927
|
+
*/
|
|
928
|
+
async handle(req) {
|
|
929
|
+
const matched = this.match(req.method, req.path);
|
|
930
|
+
if (!matched) {
|
|
931
|
+
return {
|
|
932
|
+
status: 404,
|
|
933
|
+
body: { error: `Cannot ${req.method} ${req.path}` }
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
if (matched.isStream) {
|
|
937
|
+
return {
|
|
938
|
+
status: 400,
|
|
939
|
+
body: { error: `${req.path} is a streaming route, please use SSE or ctx.http.stream` }
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
if (!matched.handler) {
|
|
943
|
+
return {
|
|
944
|
+
status: 404,
|
|
945
|
+
body: { error: `Cannot ${req.method} ${req.path}` }
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
const requestWithParams = {
|
|
949
|
+
...req,
|
|
950
|
+
params: { ...req.params, ...matched.params }
|
|
951
|
+
};
|
|
952
|
+
const rawResult = await matched.handler(requestWithParams);
|
|
953
|
+
if (rawResult !== null && typeof rawResult === "object" && "body" in rawResult && (typeof rawResult.status === "number" || rawResult.status === void 0)) {
|
|
954
|
+
const resp = rawResult;
|
|
955
|
+
return {
|
|
956
|
+
status: resp.status ?? 200,
|
|
957
|
+
headers: resp.headers,
|
|
958
|
+
body: resp.body
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
return {
|
|
962
|
+
status: 200,
|
|
963
|
+
body: rawResult
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* 执行流式请求
|
|
968
|
+
*/
|
|
969
|
+
async handleStream(req, stream) {
|
|
970
|
+
const matched = this.match(req.method, req.path);
|
|
971
|
+
if (!matched || !matched.streamHandler) {
|
|
972
|
+
stream.error(new Error(`Cannot ${req.method} ${req.path} (Stream route not found)`));
|
|
973
|
+
stream.end();
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const requestWithParams = {
|
|
977
|
+
...req,
|
|
978
|
+
params: { ...req.params, ...matched.params }
|
|
979
|
+
};
|
|
980
|
+
try {
|
|
981
|
+
await matched.streamHandler(requestWithParams, stream);
|
|
982
|
+
} catch (err) {
|
|
983
|
+
if (!stream.isClosed) {
|
|
984
|
+
stream.error(err instanceof Error ? err : new Error(String(err)));
|
|
985
|
+
stream.end();
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* 获取所有已注册的路由元数据(用于调试或内省)
|
|
991
|
+
*/
|
|
992
|
+
getRegisteredRoutes() {
|
|
993
|
+
return this.routes.map((r) => ({
|
|
994
|
+
method: r.method,
|
|
995
|
+
pattern: r.pattern,
|
|
996
|
+
isStream: r.isStream
|
|
997
|
+
}));
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* 清空所有路由
|
|
1001
|
+
*/
|
|
1002
|
+
clear() {
|
|
1003
|
+
this.routes = [];
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
});
|
|
1008
|
+
|
|
808
1009
|
// server/utils/logger.ts
|
|
809
1010
|
function createLogger(component) {
|
|
810
1011
|
return logger.child({ component });
|
|
@@ -1268,10 +1469,17 @@ async function buildContext(serviceRegistry, tracker, pluginId, manifest, db2, s
|
|
|
1268
1469
|
}));
|
|
1269
1470
|
}
|
|
1270
1471
|
};
|
|
1472
|
+
const httpRouter = new PluginHttpRouter();
|
|
1473
|
+
tracker.track(pluginId, {
|
|
1474
|
+
dispose: () => {
|
|
1475
|
+
httpRouter.clear();
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1271
1478
|
return {
|
|
1272
1479
|
services,
|
|
1273
1480
|
pluginId,
|
|
1274
1481
|
manifest,
|
|
1482
|
+
http: httpRouter,
|
|
1275
1483
|
resolve: (token) => {
|
|
1276
1484
|
if (typeof token === "string") {
|
|
1277
1485
|
return serviceRegistry.resolveByName(token);
|
|
@@ -1316,6 +1524,7 @@ var init_context_builder = __esm({
|
|
|
1316
1524
|
"packages/core/plugin-host/context-builder.ts"() {
|
|
1317
1525
|
init_types();
|
|
1318
1526
|
init_config_service();
|
|
1527
|
+
init_http_router();
|
|
1319
1528
|
init_logger();
|
|
1320
1529
|
init_interfaces();
|
|
1321
1530
|
init_plugin_namespace();
|
|
@@ -1577,8 +1786,11 @@ var init_service_host = __esm({
|
|
|
1577
1786
|
* emit a warning.
|
|
1578
1787
|
*/
|
|
1579
1788
|
this.pendingCommandExecutes = /* @__PURE__ */ new Map();
|
|
1789
|
+
this.pendingHttpRequests = /* @__PURE__ */ new Map();
|
|
1580
1790
|
this.registeredCommandTypes = /* @__PURE__ */ new Set();
|
|
1581
1791
|
this.registeredActionIds = /* @__PURE__ */ new Set();
|
|
1792
|
+
this.activeStreams = /* @__PURE__ */ new Map();
|
|
1793
|
+
this.registeredRoutes = [];
|
|
1582
1794
|
}
|
|
1583
1795
|
// ── Public accessors ───────────────────────────────────────────────────
|
|
1584
1796
|
/** The plugin actor ID bound to this ServiceHost. */
|
|
@@ -1640,6 +1852,76 @@ var init_service_host = __esm({
|
|
|
1640
1852
|
}
|
|
1641
1853
|
break;
|
|
1642
1854
|
}
|
|
1855
|
+
case "httpResponse": {
|
|
1856
|
+
const rMsg = msg;
|
|
1857
|
+
const pending = this.pendingHttpRequests.get(rMsg.invokeId);
|
|
1858
|
+
if (pending) {
|
|
1859
|
+
this.pendingHttpRequests.delete(rMsg.invokeId);
|
|
1860
|
+
clearTimeout(pending.timer);
|
|
1861
|
+
if (rMsg.error) {
|
|
1862
|
+
const err = new Error(rMsg.error.message);
|
|
1863
|
+
err.stack = rMsg.error.stack;
|
|
1864
|
+
pending.reject(err);
|
|
1865
|
+
} else {
|
|
1866
|
+
pending.resolve(rMsg.response ?? { status: 200, body: null });
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
break;
|
|
1870
|
+
}
|
|
1871
|
+
case "httpStreamChunk": {
|
|
1872
|
+
const cMsg = msg;
|
|
1873
|
+
const active = this.activeStreams.get(cMsg.streamId);
|
|
1874
|
+
if (active) {
|
|
1875
|
+
if (active.firstChunkTimer) {
|
|
1876
|
+
clearTimeout(active.firstChunkTimer);
|
|
1877
|
+
active.firstChunkTimer = void 0;
|
|
1878
|
+
}
|
|
1879
|
+
if (active.idleTimer) {
|
|
1880
|
+
clearTimeout(active.idleTimer);
|
|
1881
|
+
active.idleTimer = setTimeout(() => {
|
|
1882
|
+
this.abortStream(cMsg.streamId, "Stream idle timeout (60s)");
|
|
1883
|
+
active.stream.error(new Error("Stream idle timeout (60s)"));
|
|
1884
|
+
active.stream.end();
|
|
1885
|
+
}, 6e4);
|
|
1886
|
+
}
|
|
1887
|
+
const rawData = cMsg.data;
|
|
1888
|
+
const payloadLength = typeof rawData === "string" ? Buffer.byteLength(rawData) : Buffer.byteLength(JSON.stringify(rawData));
|
|
1889
|
+
if (payloadLength > 65536) {
|
|
1890
|
+
this.abortStream(cMsg.streamId, "Chunk size exceeded 64KB limit");
|
|
1891
|
+
active.stream.error(new Error("Chunk size exceeded 64KB limit"));
|
|
1892
|
+
active.stream.end();
|
|
1893
|
+
break;
|
|
1894
|
+
}
|
|
1895
|
+
active.stream.write(cMsg.data, cMsg.event, cMsg.id);
|
|
1896
|
+
}
|
|
1897
|
+
break;
|
|
1898
|
+
}
|
|
1899
|
+
case "httpStreamEnd": {
|
|
1900
|
+
const eMsg = msg;
|
|
1901
|
+
const active = this.activeStreams.get(eMsg.streamId);
|
|
1902
|
+
if (active) {
|
|
1903
|
+
active.cleanup();
|
|
1904
|
+
this.activeStreams.delete(eMsg.streamId);
|
|
1905
|
+
active.stream.end();
|
|
1906
|
+
}
|
|
1907
|
+
break;
|
|
1908
|
+
}
|
|
1909
|
+
case "httpStreamError": {
|
|
1910
|
+
const errMsg = msg;
|
|
1911
|
+
const active = this.activeStreams.get(errMsg.streamId);
|
|
1912
|
+
if (active) {
|
|
1913
|
+
active.cleanup();
|
|
1914
|
+
this.activeStreams.delete(errMsg.streamId);
|
|
1915
|
+
active.stream.error(new Error(errMsg.error.message));
|
|
1916
|
+
active.stream.end();
|
|
1917
|
+
}
|
|
1918
|
+
break;
|
|
1919
|
+
}
|
|
1920
|
+
case "routesRegistered": {
|
|
1921
|
+
const rMsg = msg;
|
|
1922
|
+
this.registeredRoutes = rMsg.routes || [];
|
|
1923
|
+
break;
|
|
1924
|
+
}
|
|
1643
1925
|
case "activated":
|
|
1644
1926
|
case "deactivated":
|
|
1645
1927
|
break;
|
|
@@ -1761,6 +2043,162 @@ var init_service_host = __esm({
|
|
|
1761
2043
|
}
|
|
1762
2044
|
this.registeredActionIds.clear();
|
|
1763
2045
|
}
|
|
2046
|
+
if (this.pendingHttpRequests.size > 0) {
|
|
2047
|
+
for (const [, pending] of this.pendingHttpRequests) {
|
|
2048
|
+
clearTimeout(pending.timer);
|
|
2049
|
+
pending.reject(new Error(`Worker for plugin "${this.pluginActorId}" was terminated`));
|
|
2050
|
+
}
|
|
2051
|
+
this.pendingHttpRequests.clear();
|
|
2052
|
+
}
|
|
2053
|
+
if (this.activeStreams.size > 0) {
|
|
2054
|
+
for (const [streamId, active] of this.activeStreams) {
|
|
2055
|
+
active.cleanup();
|
|
2056
|
+
try {
|
|
2057
|
+
active.transport.postMessage({
|
|
2058
|
+
type: "httpStreamAbort",
|
|
2059
|
+
streamId,
|
|
2060
|
+
reason: `Worker for plugin "${this.pluginActorId}" was terminated`
|
|
2061
|
+
});
|
|
2062
|
+
} catch {
|
|
2063
|
+
}
|
|
2064
|
+
active.stream.end();
|
|
2065
|
+
}
|
|
2066
|
+
this.activeStreams.clear();
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
/**
|
|
2070
|
+
* 中止指定的 Worker HTTP 流
|
|
2071
|
+
*/
|
|
2072
|
+
abortStream(streamId, reason) {
|
|
2073
|
+
const active = this.activeStreams.get(streamId);
|
|
2074
|
+
if (!active) return;
|
|
2075
|
+
active.cleanup();
|
|
2076
|
+
this.activeStreams.delete(streamId);
|
|
2077
|
+
try {
|
|
2078
|
+
active.transport.postMessage({
|
|
2079
|
+
type: "httpStreamAbort",
|
|
2080
|
+
streamId,
|
|
2081
|
+
reason
|
|
2082
|
+
});
|
|
2083
|
+
} catch {
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* 向 Worker 隔离线程派发 HTTP 请求 DTO,并等待响应(带超时守卫)
|
|
2088
|
+
*
|
|
2089
|
+
* @param transport - Worker 通信运输层
|
|
2090
|
+
* @param request - 纯 DTO 请求对象
|
|
2091
|
+
* @param timeoutMs - 超时时间(毫秒,默认 5000ms)
|
|
2092
|
+
*/
|
|
2093
|
+
async dispatchHttpRequest(transport, request, timeoutMs = 5e3) {
|
|
2094
|
+
const invokeId = globalThis.crypto.randomUUID();
|
|
2095
|
+
return new Promise((resolve2, reject) => {
|
|
2096
|
+
const timer = setTimeout(() => {
|
|
2097
|
+
if (this.pendingHttpRequests.has(invokeId)) {
|
|
2098
|
+
this.pendingHttpRequests.delete(invokeId);
|
|
2099
|
+
const err = new Error(`Plugin API request timed out after ${timeoutMs}ms`);
|
|
2100
|
+
err.name = "GatewayTimeoutError";
|
|
2101
|
+
reject(err);
|
|
2102
|
+
}
|
|
2103
|
+
}, timeoutMs);
|
|
2104
|
+
this.pendingHttpRequests.set(invokeId, {
|
|
2105
|
+
resolve: (val) => {
|
|
2106
|
+
clearTimeout(timer);
|
|
2107
|
+
resolve2(val);
|
|
2108
|
+
},
|
|
2109
|
+
reject: (err) => {
|
|
2110
|
+
clearTimeout(timer);
|
|
2111
|
+
reject(err);
|
|
2112
|
+
},
|
|
2113
|
+
timer
|
|
2114
|
+
});
|
|
2115
|
+
try {
|
|
2116
|
+
transport.postMessage({
|
|
2117
|
+
type: "httpRequest",
|
|
2118
|
+
invokeId,
|
|
2119
|
+
request
|
|
2120
|
+
});
|
|
2121
|
+
} catch (err) {
|
|
2122
|
+
clearTimeout(timer);
|
|
2123
|
+
this.pendingHttpRequests.delete(invokeId);
|
|
2124
|
+
reject(err);
|
|
2125
|
+
}
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
/**
|
|
2129
|
+
* 向 Worker 隔离线程派发 HTTP SSE 流式请求,并桥接流式数据块(带超时看门狗与中断感知)
|
|
2130
|
+
*
|
|
2131
|
+
* @param transport - Worker 通信运输层
|
|
2132
|
+
* @param request - 纯 DTO 请求对象
|
|
2133
|
+
* @param stream - 主线程流式响应写入器
|
|
2134
|
+
* @param maxLifetimeMs - 最大生命周期超时(默认 300,000ms = 5分钟)
|
|
2135
|
+
*/
|
|
2136
|
+
async dispatchHttpStream(transport, request, stream, maxLifetimeMs = 3e5) {
|
|
2137
|
+
const streamId = globalThis.crypto.randomUUID();
|
|
2138
|
+
let firstChunkTimer = setTimeout(() => {
|
|
2139
|
+
const active = this.activeStreams.get(streamId);
|
|
2140
|
+
if (active) {
|
|
2141
|
+
this.abortStream(streamId, "First chunk timeout (10s)");
|
|
2142
|
+
stream.error(new Error("First chunk timeout (10s)"));
|
|
2143
|
+
stream.end();
|
|
2144
|
+
}
|
|
2145
|
+
}, 1e4);
|
|
2146
|
+
let idleTimer = setTimeout(() => {
|
|
2147
|
+
const active = this.activeStreams.get(streamId);
|
|
2148
|
+
if (active) {
|
|
2149
|
+
this.abortStream(streamId, "Stream idle timeout (60s)");
|
|
2150
|
+
stream.error(new Error("Stream idle timeout (60s)"));
|
|
2151
|
+
stream.end();
|
|
2152
|
+
}
|
|
2153
|
+
}, 6e4);
|
|
2154
|
+
const lifetimeTimer = setTimeout(() => {
|
|
2155
|
+
const active = this.activeStreams.get(streamId);
|
|
2156
|
+
if (active) {
|
|
2157
|
+
this.abortStream(streamId, `Stream max lifetime exceeded (${Math.round(maxLifetimeMs / 1e3)}s)`);
|
|
2158
|
+
stream.error(new Error(`Stream max lifetime exceeded (${Math.round(maxLifetimeMs / 1e3)}s)`));
|
|
2159
|
+
stream.end();
|
|
2160
|
+
}
|
|
2161
|
+
}, maxLifetimeMs);
|
|
2162
|
+
const cleanup = () => {
|
|
2163
|
+
if (firstChunkTimer) {
|
|
2164
|
+
clearTimeout(firstChunkTimer);
|
|
2165
|
+
firstChunkTimer = void 0;
|
|
2166
|
+
}
|
|
2167
|
+
if (idleTimer) {
|
|
2168
|
+
clearTimeout(idleTimer);
|
|
2169
|
+
idleTimer = void 0;
|
|
2170
|
+
}
|
|
2171
|
+
clearTimeout(lifetimeTimer);
|
|
2172
|
+
};
|
|
2173
|
+
stream.onClose(() => {
|
|
2174
|
+
this.abortStream(streamId, "Client closed connection");
|
|
2175
|
+
});
|
|
2176
|
+
this.activeStreams.set(streamId, {
|
|
2177
|
+
stream,
|
|
2178
|
+
transport,
|
|
2179
|
+
firstChunkTimer,
|
|
2180
|
+
idleTimer,
|
|
2181
|
+
lifetimeTimer,
|
|
2182
|
+
cleanup
|
|
2183
|
+
});
|
|
2184
|
+
try {
|
|
2185
|
+
transport.postMessage({
|
|
2186
|
+
type: "httpStreamStart",
|
|
2187
|
+
streamId,
|
|
2188
|
+
request
|
|
2189
|
+
});
|
|
2190
|
+
} catch (err) {
|
|
2191
|
+
cleanup();
|
|
2192
|
+
this.activeStreams.delete(streamId);
|
|
2193
|
+
stream.error(err);
|
|
2194
|
+
stream.end();
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* 获取由 Worker 注册的所有路由(含是否为流式路由)
|
|
2199
|
+
*/
|
|
2200
|
+
getRegisteredRoutes() {
|
|
2201
|
+
return this.registeredRoutes;
|
|
1764
2202
|
}
|
|
1765
2203
|
// ── Invoke handling (core RPC logic) ───────────────────────────────────
|
|
1766
2204
|
/**
|
|
@@ -2503,10 +2941,153 @@ function createServiceProxies(serviceTokens) {
|
|
|
2503
2941
|
return services;
|
|
2504
2942
|
}
|
|
2505
2943
|
|
|
2944
|
+
// \u2500\u2500 V5.2: Worker \u7AEF RESTful Router \u2500\u2500
|
|
2945
|
+
function compileRoutePattern(pattern) {
|
|
2946
|
+
var normalized = pattern.indexOf('/') === 0 ? pattern : '/' + pattern;
|
|
2947
|
+
var paramNames = [];
|
|
2948
|
+
var regexStr = normalized
|
|
2949
|
+
.replace(/:([a-zA-Z0-9_]+)/g, function(_m, p) {
|
|
2950
|
+
paramNames.push(p);
|
|
2951
|
+
return '([^/]+)';
|
|
2952
|
+
})
|
|
2953
|
+
.replace(/\\*/g, function() {
|
|
2954
|
+
paramNames.push('wildcard');
|
|
2955
|
+
return '(.*)';
|
|
2956
|
+
});
|
|
2957
|
+
return { regex: new RegExp('^' + regexStr + '$'), paramNames: paramNames };
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
function createPluginHttpRouter() {
|
|
2961
|
+
var routes = [];
|
|
2962
|
+
var routeFn = function(method, path, handler) {
|
|
2963
|
+
var upperMethod = method.toUpperCase();
|
|
2964
|
+
var normalized = path.indexOf('/') === 0 ? path : '/' + path;
|
|
2965
|
+
var compiled = compileRoutePattern(normalized);
|
|
2966
|
+
routes.push({
|
|
2967
|
+
method: upperMethod,
|
|
2968
|
+
pattern: normalized,
|
|
2969
|
+
regex: compiled.regex,
|
|
2970
|
+
paramNames: compiled.paramNames,
|
|
2971
|
+
handler: handler,
|
|
2972
|
+
isStream: false
|
|
2973
|
+
});
|
|
2974
|
+
};
|
|
2975
|
+
var routeStreamFn = function(method, path, handler) {
|
|
2976
|
+
var upperMethod = method.toUpperCase();
|
|
2977
|
+
var normalized = path.indexOf('/') === 0 ? path : '/' + path;
|
|
2978
|
+
var compiled = compileRoutePattern(normalized);
|
|
2979
|
+
routes.push({
|
|
2980
|
+
method: upperMethod,
|
|
2981
|
+
pattern: normalized,
|
|
2982
|
+
regex: compiled.regex,
|
|
2983
|
+
paramNames: compiled.paramNames,
|
|
2984
|
+
streamHandler: handler,
|
|
2985
|
+
isStream: true
|
|
2986
|
+
});
|
|
2987
|
+
};
|
|
2988
|
+
|
|
2989
|
+
return {
|
|
2990
|
+
get: function(path, handler) { routeFn('GET', path, handler); },
|
|
2991
|
+
post: function(path, handler) { routeFn('POST', path, handler); },
|
|
2992
|
+
put: function(path, handler) { routeFn('PUT', path, handler); },
|
|
2993
|
+
patch: function(path, handler) { routeFn('PATCH', path, handler); },
|
|
2994
|
+
delete: function(path, handler) { routeFn('DELETE', path, handler); },
|
|
2995
|
+
route: routeFn,
|
|
2996
|
+
stream: function(methodOrPath, pathOrHandler, maybeHandler) {
|
|
2997
|
+
if (typeof pathOrHandler === 'function') {
|
|
2998
|
+
var path = methodOrPath;
|
|
2999
|
+
var handler = pathOrHandler;
|
|
3000
|
+
routeStreamFn('GET', path, handler);
|
|
3001
|
+
routeStreamFn('POST', path, handler);
|
|
3002
|
+
} else {
|
|
3003
|
+
var method = methodOrPath.toUpperCase();
|
|
3004
|
+
var path = pathOrHandler;
|
|
3005
|
+
var handler = maybeHandler;
|
|
3006
|
+
routeStreamFn(method, path, handler);
|
|
3007
|
+
}
|
|
3008
|
+
},
|
|
3009
|
+
match: function(method, path) {
|
|
3010
|
+
var upperMethod = method.toUpperCase();
|
|
3011
|
+
var normalized = path.indexOf('/') === 0 ? path : '/' + path;
|
|
3012
|
+
for (var i = 0; i < routes.length; i++) {
|
|
3013
|
+
var entry = routes[i];
|
|
3014
|
+
if (entry.method !== upperMethod) continue;
|
|
3015
|
+
var m = normalized.match(entry.regex);
|
|
3016
|
+
if (m) {
|
|
3017
|
+
var params = {};
|
|
3018
|
+
for (var j = 0; j < entry.paramNames.length; j++) {
|
|
3019
|
+
params[entry.paramNames[j]] = decodeURIComponent(m[j + 1] || '');
|
|
3020
|
+
}
|
|
3021
|
+
return {
|
|
3022
|
+
handler: entry.handler,
|
|
3023
|
+
streamHandler: entry.streamHandler,
|
|
3024
|
+
isStream: entry.isStream,
|
|
3025
|
+
params: params
|
|
3026
|
+
};
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
return null;
|
|
3030
|
+
},
|
|
3031
|
+
handle: async function(req) {
|
|
3032
|
+
var matched = this.match(req.method, req.path);
|
|
3033
|
+
if (!matched) {
|
|
3034
|
+
return { status: 404, body: { error: 'Cannot ' + req.method + ' ' + req.path } };
|
|
3035
|
+
}
|
|
3036
|
+
if (matched.isStream) {
|
|
3037
|
+
return { status: 400, body: { error: req.path + ' is a streaming route, please use SSE or ctx.http.stream' } };
|
|
3038
|
+
}
|
|
3039
|
+
if (!matched.handler) {
|
|
3040
|
+
return { status: 404, body: { error: 'Cannot ' + req.method + ' ' + req.path } };
|
|
3041
|
+
}
|
|
3042
|
+
var requestWithParams = Object.assign({}, req, {
|
|
3043
|
+
params: Object.assign({}, req.params, matched.params)
|
|
3044
|
+
});
|
|
3045
|
+
var rawResult = await matched.handler(requestWithParams);
|
|
3046
|
+
if (rawResult !== null && typeof rawResult === 'object' && 'body' in rawResult && (typeof rawResult.status === 'number' || rawResult.status === undefined)) {
|
|
3047
|
+
return {
|
|
3048
|
+
status: rawResult.status !== undefined ? rawResult.status : 200,
|
|
3049
|
+
headers: rawResult.headers,
|
|
3050
|
+
body: rawResult.body
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
3053
|
+
return { status: 200, body: rawResult };
|
|
3054
|
+
},
|
|
3055
|
+
handleStream: async function(req, stream) {
|
|
3056
|
+
var matched = this.match(req.method, req.path);
|
|
3057
|
+
if (!matched || !matched.streamHandler) {
|
|
3058
|
+
stream.error(new Error('Cannot ' + req.method + ' ' + req.path + ' (Stream route not found)'));
|
|
3059
|
+
stream.end();
|
|
3060
|
+
return;
|
|
3061
|
+
}
|
|
3062
|
+
var requestWithParams = Object.assign({}, req, {
|
|
3063
|
+
params: Object.assign({}, req.params, matched.params)
|
|
3064
|
+
});
|
|
3065
|
+
try {
|
|
3066
|
+
await matched.streamHandler(requestWithParams, stream);
|
|
3067
|
+
} catch (err) {
|
|
3068
|
+
if (!stream.isClosed) {
|
|
3069
|
+
stream.error(err instanceof Error ? err : new Error(String(err)));
|
|
3070
|
+
stream.end();
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
},
|
|
3074
|
+
getRegisteredRoutes: function() {
|
|
3075
|
+
return routes.map(function(r) {
|
|
3076
|
+
return { method: r.method, pattern: r.pattern, isStream: r.isStream };
|
|
3077
|
+
});
|
|
3078
|
+
},
|
|
3079
|
+
clear: function() {
|
|
3080
|
+
routes = [];
|
|
3081
|
+
}
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
3084
|
+
|
|
2506
3085
|
// \u2500\u2500 \u5355\u6D88\u606F\u5904\u7406\u5668 \u2500\u2500
|
|
2507
3086
|
|
|
2508
3087
|
var eventBusProxy = null;
|
|
2509
3088
|
var registeredCommandHandlers = new Map();
|
|
3089
|
+
var pluginHttpRouter = createPluginHttpRouter();
|
|
3090
|
+
var activeWorkerStreams = new Map();
|
|
2510
3091
|
|
|
2511
3092
|
parentPort.on('message', async function(msg) {
|
|
2512
3093
|
// 1. \u8F6C\u53D1\u7684\u5E73\u53F0\u4E8B\u4EF6\u5206\u53D1
|
|
@@ -2544,6 +3125,117 @@ parentPort.on('message', async function(msg) {
|
|
|
2544
3125
|
return;
|
|
2545
3126
|
}
|
|
2546
3127
|
|
|
3128
|
+
// 1c. Intercept HTTP request from host (V5.2)
|
|
3129
|
+
if (msg && msg.type === 'httpRequest') {
|
|
3130
|
+
try {
|
|
3131
|
+
var res = await pluginHttpRouter.handle(msg.request);
|
|
3132
|
+
parentPort.postMessage({
|
|
3133
|
+
type: 'httpResponse',
|
|
3134
|
+
invokeId: msg.invokeId,
|
|
3135
|
+
response: res
|
|
3136
|
+
});
|
|
3137
|
+
} catch (err) {
|
|
3138
|
+
parentPort.postMessage({
|
|
3139
|
+
type: 'httpResponse',
|
|
3140
|
+
invokeId: msg.invokeId,
|
|
3141
|
+
error: {
|
|
3142
|
+
message: (err && err.message) ? err.message : String(err),
|
|
3143
|
+
stack: (err && err.stack) || ''
|
|
3144
|
+
}
|
|
3145
|
+
});
|
|
3146
|
+
}
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
// 1d. Intercept HTTP stream start from host (V5.3)
|
|
3151
|
+
if (msg && msg.type === 'httpStreamStart') {
|
|
3152
|
+
var streamId = msg.streamId;
|
|
3153
|
+
var isStreamClosed = false;
|
|
3154
|
+
var closeCallbacks = [];
|
|
3155
|
+
|
|
3156
|
+
var streamWriter = {
|
|
3157
|
+
get isClosed() {
|
|
3158
|
+
return isStreamClosed;
|
|
3159
|
+
},
|
|
3160
|
+
write: function(data, event, id) {
|
|
3161
|
+
if (isStreamClosed) return false;
|
|
3162
|
+
var rawStr = typeof data === 'string' ? data : JSON.stringify(data);
|
|
3163
|
+
if (rawStr.length > 65536) {
|
|
3164
|
+
throw new Error('Chunk size exceeds 64KB limit');
|
|
3165
|
+
}
|
|
3166
|
+
parentPort.postMessage({
|
|
3167
|
+
type: 'httpStreamChunk',
|
|
3168
|
+
streamId: streamId,
|
|
3169
|
+
data: data,
|
|
3170
|
+
event: event,
|
|
3171
|
+
id: id
|
|
3172
|
+
});
|
|
3173
|
+
return true;
|
|
3174
|
+
},
|
|
3175
|
+
end: function() {
|
|
3176
|
+
if (isStreamClosed) return;
|
|
3177
|
+
isStreamClosed = true;
|
|
3178
|
+
activeWorkerStreams.delete(streamId);
|
|
3179
|
+
parentPort.postMessage({
|
|
3180
|
+
type: 'httpStreamEnd',
|
|
3181
|
+
streamId: streamId
|
|
3182
|
+
});
|
|
3183
|
+
},
|
|
3184
|
+
error: function(err) {
|
|
3185
|
+
if (isStreamClosed) return;
|
|
3186
|
+
isStreamClosed = true;
|
|
3187
|
+
activeWorkerStreams.delete(streamId);
|
|
3188
|
+
parentPort.postMessage({
|
|
3189
|
+
type: 'httpStreamError',
|
|
3190
|
+
streamId: streamId,
|
|
3191
|
+
error: {
|
|
3192
|
+
message: (err && err.message) ? err.message : String(err),
|
|
3193
|
+
stack: (err && err.stack) || ''
|
|
3194
|
+
}
|
|
3195
|
+
});
|
|
3196
|
+
},
|
|
3197
|
+
onClose: function(callback) {
|
|
3198
|
+
if (typeof callback === 'function') {
|
|
3199
|
+
if (isStreamClosed) {
|
|
3200
|
+
try { callback(); } catch(e) {}
|
|
3201
|
+
} else {
|
|
3202
|
+
closeCallbacks.push(callback);
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
};
|
|
3207
|
+
|
|
3208
|
+
activeWorkerStreams.set(streamId, {
|
|
3209
|
+
stream: streamWriter,
|
|
3210
|
+
abort: function() {
|
|
3211
|
+
if (isStreamClosed) return;
|
|
3212
|
+
isStreamClosed = true;
|
|
3213
|
+
activeWorkerStreams.delete(streamId);
|
|
3214
|
+
for (var i = 0; i < closeCallbacks.length; i++) {
|
|
3215
|
+
try {
|
|
3216
|
+
closeCallbacks[i]();
|
|
3217
|
+
} catch(e) {
|
|
3218
|
+
console.error('[WorkerStream] Error in onClose callback:', e);
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
}
|
|
3222
|
+
});
|
|
3223
|
+
|
|
3224
|
+
pluginHttpRouter.handleStream(msg.request, streamWriter).catch(function(err) {
|
|
3225
|
+
streamWriter.error(err);
|
|
3226
|
+
});
|
|
3227
|
+
return;
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
// 1e. Intercept HTTP stream abort from host (V5.3)
|
|
3231
|
+
if (msg && msg.type === 'httpStreamAbort') {
|
|
3232
|
+
var activeToAbort = activeWorkerStreams.get(msg.streamId);
|
|
3233
|
+
if (activeToAbort) {
|
|
3234
|
+
activeToAbort.abort();
|
|
3235
|
+
}
|
|
3236
|
+
return;
|
|
3237
|
+
}
|
|
3238
|
+
|
|
2547
3239
|
// 2. RPC \u7ED3\u679C/\u9519\u8BEF\u5206\u53D1\uFF08\u6709 invokeId \u4E14\u5728 pendingCalls \u4E2D\uFF09
|
|
2548
3240
|
if (msg && msg.invokeId && pendingCalls.has(msg.invokeId)) {
|
|
2549
3241
|
var pending = pendingCalls.get(msg.invokeId);
|
|
@@ -2786,12 +3478,21 @@ parentPort.on('message', async function(msg) {
|
|
|
2786
3478
|
}
|
|
2787
3479
|
}
|
|
2788
3480
|
throw new Error('Plugin cannot require non-shared module: ' + moduleName);
|
|
2789
|
-
}
|
|
3481
|
+
},
|
|
3482
|
+
http: pluginHttpRouter
|
|
2790
3483
|
};
|
|
2791
3484
|
|
|
2792
3485
|
// \u8C03\u7528 activate
|
|
2793
3486
|
await plugin.activate(ctx, msg.prevState);
|
|
2794
3487
|
|
|
3488
|
+
// \u4E0A\u62A5\u5DF2\u6CE8\u518C\u8DEF\u7531\u5143\u6570\u636E\u7ED9\u5BBF\u4E3B
|
|
3489
|
+
if (pluginHttpRouter) {
|
|
3490
|
+
parentPort.postMessage({
|
|
3491
|
+
type: 'routesRegistered',
|
|
3492
|
+
routes: pluginHttpRouter.getRegisteredRoutes()
|
|
3493
|
+
});
|
|
3494
|
+
}
|
|
3495
|
+
|
|
2795
3496
|
parentPort.postMessage({ type: 'activated' });
|
|
2796
3497
|
|
|
2797
3498
|
// 4. \u505C\u7528\u8BF7\u6C42\uFF08\u6FC0\u6D3B\u540E\u6CE8\u518C\uFF0C\u907F\u514D\u7ADE\u4E89\uFF09
|
|
@@ -2804,12 +3505,21 @@ parentPort.on('message', async function(msg) {
|
|
|
2804
3505
|
state = await plugin.deactivate();
|
|
2805
3506
|
}
|
|
2806
3507
|
} finally {
|
|
2807
|
-
// \u6E05\u7406 pending calls
|
|
3508
|
+
// \u6E05\u7406 pending calls\u3001\u6D3B\u8DC3\u6D41\u3001\u4E8B\u4EF6\u4EE3\u7406\u4E0E HTTP \u8DEF\u7531
|
|
2808
3509
|
pendingCalls.clear();
|
|
3510
|
+
if (activeWorkerStreams) {
|
|
3511
|
+
for (var s of activeWorkerStreams.values()) {
|
|
3512
|
+
try { s.abort(); } catch(e) {}
|
|
3513
|
+
}
|
|
3514
|
+
activeWorkerStreams.clear();
|
|
3515
|
+
}
|
|
2809
3516
|
if (eventBusProxy) {
|
|
2810
3517
|
eventBusProxy.disposeAll();
|
|
2811
3518
|
eventBusProxy = null;
|
|
2812
3519
|
}
|
|
3520
|
+
if (pluginHttpRouter) {
|
|
3521
|
+
pluginHttpRouter.clear();
|
|
3522
|
+
}
|
|
2813
3523
|
parentPort.postMessage({ type: 'deactivated', state: state });
|
|
2814
3524
|
}
|
|
2815
3525
|
}
|
|
@@ -3822,6 +4532,20 @@ var deploySchema = import_zod.z.object({
|
|
|
3822
4532
|
staticRoute: import_zod.z.string().optional(),
|
|
3823
4533
|
staticDir: import_zod.z.string().optional()
|
|
3824
4534
|
}).optional();
|
|
4535
|
+
var apiRouteSchema = import_zod.z.object({
|
|
4536
|
+
method: import_zod.z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
|
4537
|
+
path: import_zod.z.string().min(1, { error: "api route path \u4E0D\u80FD\u4E3A\u7A7A" }),
|
|
4538
|
+
auth: import_zod.z.boolean().optional(),
|
|
4539
|
+
roles: import_zod.z.array(import_zod.z.string()).optional(),
|
|
4540
|
+
rateLimit: import_zod.z.object({
|
|
4541
|
+
windowMs: import_zod.z.number().optional(),
|
|
4542
|
+
max: import_zod.z.number().optional()
|
|
4543
|
+
}).optional()
|
|
4544
|
+
});
|
|
4545
|
+
var apiSchema = import_zod.z.object({
|
|
4546
|
+
baseRoute: import_zod.z.string().optional(),
|
|
4547
|
+
routes: import_zod.z.array(apiRouteSchema).optional()
|
|
4548
|
+
}).optional();
|
|
3825
4549
|
var requiresItemSchema = import_zod.z.string().regex(
|
|
3826
4550
|
/^@[\w-]+\/[\w-]+:I\w+(?:@[\^~]?\d+\.\d+\.\d+(?:-[\w.]+)?)?$/,
|
|
3827
4551
|
{ message: "requires/optional \u6761\u76EE\u683C\u5F0F\u65E0\u6548\u3002\u9700\u8981 @scope/domain:IServiceName \u6216 @scope/domain:IServiceName@^x.y.z" }
|
|
@@ -3851,7 +4575,8 @@ var manifestSchema = import_zod.z.object({
|
|
|
3851
4575
|
}).optional(),
|
|
3852
4576
|
contributes: contributesSchema,
|
|
3853
4577
|
classroomTools: import_zod.z.array(import_zod.z.unknown()).optional(),
|
|
3854
|
-
deploy: deploySchema
|
|
4578
|
+
deploy: deploySchema,
|
|
4579
|
+
api: apiSchema
|
|
3855
4580
|
}).passthrough();
|
|
3856
4581
|
var requiresItemV3Schema = import_zod.z.string().regex(
|
|
3857
4582
|
/^@[\w-]+\/[\w-]+:I\w+$/,
|
|
@@ -8192,6 +8917,7 @@ function compose(middlewares) {
|
|
|
8192
8917
|
|
|
8193
8918
|
// packages/core/plugin-host/index.ts
|
|
8194
8919
|
init_types();
|
|
8920
|
+
init_http_router();
|
|
8195
8921
|
|
|
8196
8922
|
// packages/core/plugin-host/errors.ts
|
|
8197
8923
|
var PluginHostError = class extends Error {
|
|
@@ -8262,7 +8988,7 @@ var HotReloadActivationError = class extends HotReloadError {
|
|
|
8262
8988
|
init_interfaces();
|
|
8263
8989
|
|
|
8264
8990
|
// packages/core/version.ts
|
|
8265
|
-
var PLATFORM_VERSION = "0.3.
|
|
8991
|
+
var PLATFORM_VERSION = "0.3.13";
|
|
8266
8992
|
var OPENLEARN_VERSION = PLATFORM_VERSION;
|
|
8267
8993
|
|
|
8268
8994
|
// packages/core/plugin-host/plugin-runtime-composition.ts
|
|
@@ -8604,6 +9330,7 @@ var PluginDistributionManager = class {
|
|
|
8604
9330
|
};
|
|
8605
9331
|
|
|
8606
9332
|
// packages/core/plugin-host/index.ts
|
|
9333
|
+
init_http_router();
|
|
8607
9334
|
var VALID_TRANSITIONS = {
|
|
8608
9335
|
["installed" /* INSTALLED */]: ["activating" /* ACTIVATING */, "uninstalled" /* UNINSTALLED */],
|
|
8609
9336
|
["activating" /* ACTIVATING */]: ["active" /* ACTIVE */, "error" /* ERROR */],
|
|
@@ -8717,6 +9444,125 @@ var PluginHost = class {
|
|
|
8717
9444
|
getPluginManifestPath(pluginId) {
|
|
8718
9445
|
return import_path4.default.join(this.getPluginDir(pluginId), "manifest.json");
|
|
8719
9446
|
}
|
|
9447
|
+
// ── V5.2: RESTful API Gateway Dispatcher ───────────────────────────────
|
|
9448
|
+
/**
|
|
9449
|
+
* 向插件派发 HTTP 请求(自动路由到 Worker 隔离线程或 Inline 实例)
|
|
9450
|
+
*/
|
|
9451
|
+
async dispatchHttpRequest(pluginIdOrManifestId, req, timeoutMs = 5e3) {
|
|
9452
|
+
const resolvedId = this.resolvePluginUuid(pluginIdOrManifestId);
|
|
9453
|
+
const instance = this.pluginInstances.get(resolvedId) ?? this.pluginInstances.get(pluginIdOrManifestId);
|
|
9454
|
+
if (!instance) {
|
|
9455
|
+
const state = this.pluginStates.get(resolvedId) ?? this.pluginStates.get(pluginIdOrManifestId);
|
|
9456
|
+
if (state && state !== "active" /* ACTIVE */) {
|
|
9457
|
+
return {
|
|
9458
|
+
status: 503,
|
|
9459
|
+
body: { error: `Plugin "${pluginIdOrManifestId}" is currently ${state}` }
|
|
9460
|
+
};
|
|
9461
|
+
}
|
|
9462
|
+
return {
|
|
9463
|
+
status: 404,
|
|
9464
|
+
body: { error: `Plugin "${pluginIdOrManifestId}" not found or not active` }
|
|
9465
|
+
};
|
|
9466
|
+
}
|
|
9467
|
+
if (instance.workerRef) {
|
|
9468
|
+
return instance.workerRef.serviceHost.dispatchHttpRequest(
|
|
9469
|
+
instance.workerRef.transport,
|
|
9470
|
+
req,
|
|
9471
|
+
timeoutMs
|
|
9472
|
+
);
|
|
9473
|
+
}
|
|
9474
|
+
if (instance.context?.http) {
|
|
9475
|
+
return instance.context.http.handle(req);
|
|
9476
|
+
}
|
|
9477
|
+
return {
|
|
9478
|
+
status: 404,
|
|
9479
|
+
body: { error: `Plugin "${pluginIdOrManifestId}" has no HTTP router registered` }
|
|
9480
|
+
};
|
|
9481
|
+
}
|
|
9482
|
+
/**
|
|
9483
|
+
* 判断指定插件的某个请求路径是否为流式路由 (SSE)
|
|
9484
|
+
*/
|
|
9485
|
+
isStreamRoute(pluginIdOrManifestId, method, path20) {
|
|
9486
|
+
const upperMethod = method.toUpperCase();
|
|
9487
|
+
const resolvedId = this.resolvePluginUuid(pluginIdOrManifestId);
|
|
9488
|
+
const instance = this.pluginInstances.get(resolvedId) ?? this.pluginInstances.get(pluginIdOrManifestId);
|
|
9489
|
+
const manifest = this.getPluginManifest(pluginIdOrManifestId);
|
|
9490
|
+
if (manifest?.api?.routes) {
|
|
9491
|
+
for (const r of manifest.api.routes) {
|
|
9492
|
+
if (r.method.toUpperCase() === upperMethod && r.streaming === true) {
|
|
9493
|
+
const compiled = compileRoutePattern(r.path);
|
|
9494
|
+
if (compiled.regex.test(path20)) {
|
|
9495
|
+
return true;
|
|
9496
|
+
}
|
|
9497
|
+
}
|
|
9498
|
+
}
|
|
9499
|
+
}
|
|
9500
|
+
if (!instance) return false;
|
|
9501
|
+
if (instance.context?.http) {
|
|
9502
|
+
return instance.context.http.isStream(upperMethod, path20);
|
|
9503
|
+
}
|
|
9504
|
+
if (instance.workerRef) {
|
|
9505
|
+
const routes = instance.workerRef.serviceHost.getRegisteredRoutes();
|
|
9506
|
+
for (const r of routes) {
|
|
9507
|
+
if (r.method === upperMethod && r.isStream) {
|
|
9508
|
+
const compiled = compileRoutePattern(r.pattern);
|
|
9509
|
+
if (compiled.regex.test(path20)) {
|
|
9510
|
+
return true;
|
|
9511
|
+
}
|
|
9512
|
+
}
|
|
9513
|
+
}
|
|
9514
|
+
}
|
|
9515
|
+
return false;
|
|
9516
|
+
}
|
|
9517
|
+
/**
|
|
9518
|
+
* 向插件派发 HTTP SSE 流式传输请求(自动路由到 Worker 隔离线程或 Inline 实例)
|
|
9519
|
+
*/
|
|
9520
|
+
async dispatchHttpStream(pluginIdOrManifestId, req, stream, maxLifetimeMs = 3e5) {
|
|
9521
|
+
const resolvedId = this.resolvePluginUuid(pluginIdOrManifestId);
|
|
9522
|
+
const instance = this.pluginInstances.get(resolvedId) ?? this.pluginInstances.get(pluginIdOrManifestId);
|
|
9523
|
+
if (!instance) {
|
|
9524
|
+
const state = this.pluginStates.get(resolvedId) ?? this.pluginStates.get(pluginIdOrManifestId);
|
|
9525
|
+
if (state && state !== "active" /* ACTIVE */) {
|
|
9526
|
+
stream.error(new Error(`Plugin "${pluginIdOrManifestId}" is currently ${state}`));
|
|
9527
|
+
stream.end();
|
|
9528
|
+
return;
|
|
9529
|
+
}
|
|
9530
|
+
stream.error(new Error(`Plugin "${pluginIdOrManifestId}" not found or not active`));
|
|
9531
|
+
stream.end();
|
|
9532
|
+
return;
|
|
9533
|
+
}
|
|
9534
|
+
if (instance.workerRef) {
|
|
9535
|
+
return instance.workerRef.serviceHost.dispatchHttpStream(
|
|
9536
|
+
instance.workerRef.transport,
|
|
9537
|
+
req,
|
|
9538
|
+
stream,
|
|
9539
|
+
maxLifetimeMs
|
|
9540
|
+
);
|
|
9541
|
+
}
|
|
9542
|
+
if (instance.context?.http) {
|
|
9543
|
+
return instance.context.http.handleStream(req, stream);
|
|
9544
|
+
}
|
|
9545
|
+
stream.error(new Error(`Plugin "${pluginIdOrManifestId}" has no HTTP router registered`));
|
|
9546
|
+
stream.end();
|
|
9547
|
+
}
|
|
9548
|
+
/**
|
|
9549
|
+
* 获取指定插件的 Manifest(支持已激活或 DB 中的插件)
|
|
9550
|
+
*/
|
|
9551
|
+
getPluginManifest(pluginIdOrManifestId) {
|
|
9552
|
+
const resolvedId = this.resolvePluginUuid(pluginIdOrManifestId);
|
|
9553
|
+
const instance = this.pluginInstances.get(resolvedId) ?? this.pluginInstances.get(pluginIdOrManifestId);
|
|
9554
|
+
if (instance?.manifest) return instance.manifest;
|
|
9555
|
+
const preloaded = this.preloadedPlugins.get(resolvedId) ?? this.preloadedPlugins.get(pluginIdOrManifestId);
|
|
9556
|
+
if (preloaded?.manifest) return preloaded.manifest;
|
|
9557
|
+
try {
|
|
9558
|
+
const row = this.db.prepare("SELECT manifest FROM plugins WHERE id = ?").get(resolvedId);
|
|
9559
|
+
if (row?.manifest) {
|
|
9560
|
+
return JSON.parse(row.manifest);
|
|
9561
|
+
}
|
|
9562
|
+
} catch {
|
|
9563
|
+
}
|
|
9564
|
+
return null;
|
|
9565
|
+
}
|
|
8720
9566
|
/**
|
|
8721
9567
|
* Set the WorkerManager instance (Phase 5).
|
|
8722
9568
|
* Called by Kernel after both PluginHost and WorkerManager are constructed,
|
|
@@ -9296,7 +10142,7 @@ var PluginHost = class {
|
|
|
9296
10142
|
)
|
|
9297
10143
|
]);
|
|
9298
10144
|
});
|
|
9299
|
-
this.pluginInstances.set(pluginId, { manifest, activate, deactivate });
|
|
10145
|
+
this.pluginInstances.set(pluginId, { manifest, activate, deactivate, context: ctx });
|
|
9300
10146
|
this.pluginStates.set(pluginId, "active" /* ACTIVE */);
|
|
9301
10147
|
this.db.prepare("UPDATE plugins SET status = ? WHERE id = ?").run("active", pluginId);
|
|
9302
10148
|
} catch (err) {
|
|
@@ -9422,7 +10268,8 @@ var PluginHost = class {
|
|
|
9422
10268
|
this.pluginInstances.set(pluginId, {
|
|
9423
10269
|
manifest: mergedManifest,
|
|
9424
10270
|
activate,
|
|
9425
|
-
deactivate: typeof deactivate === "function" ? deactivate : void 0
|
|
10271
|
+
deactivate: typeof deactivate === "function" ? deactivate : void 0,
|
|
10272
|
+
context: ctx
|
|
9426
10273
|
});
|
|
9427
10274
|
this.db.prepare("UPDATE plugins SET status = ? WHERE id = ?").run("active", pluginId);
|
|
9428
10275
|
if (this._hotReloadController) {
|
|
@@ -23889,6 +24736,361 @@ async function checkVersion(source, installedVersion) {
|
|
|
23889
24736
|
}
|
|
23890
24737
|
}
|
|
23891
24738
|
|
|
24739
|
+
// server/routes/plugin-api-gateway.ts
|
|
24740
|
+
var import_node_path3 = __toESM(require("node:path"), 1);
|
|
24741
|
+
init_http_router();
|
|
24742
|
+
var RESERVED_ACTIONS = /* @__PURE__ */ new Set([
|
|
24743
|
+
"config",
|
|
24744
|
+
"toggle",
|
|
24745
|
+
"contributions",
|
|
24746
|
+
"check-update",
|
|
24747
|
+
"one-click-update"
|
|
24748
|
+
]);
|
|
24749
|
+
var FORBIDDEN_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
24750
|
+
"set-cookie",
|
|
24751
|
+
"access-control-allow-origin",
|
|
24752
|
+
"access-control-allow-credentials",
|
|
24753
|
+
"access-control-allow-methods",
|
|
24754
|
+
"access-control-allow-headers",
|
|
24755
|
+
"content-security-policy",
|
|
24756
|
+
"x-frame-options",
|
|
24757
|
+
"transfer-encoding",
|
|
24758
|
+
"connection",
|
|
24759
|
+
"host"
|
|
24760
|
+
]);
|
|
24761
|
+
var MAX_PAYLOAD_BYTES = 1024 * 1024;
|
|
24762
|
+
var PluginRateLimiter = class {
|
|
24763
|
+
constructor() {
|
|
24764
|
+
this.buckets = /* @__PURE__ */ new Map();
|
|
24765
|
+
}
|
|
24766
|
+
/**
|
|
24767
|
+
* 检查并递增计数。返回 true 表示放行,false 表示限流。
|
|
24768
|
+
*/
|
|
24769
|
+
consume(key, limit, windowMs) {
|
|
24770
|
+
const now = Date.now();
|
|
24771
|
+
const entry = this.buckets.get(key);
|
|
24772
|
+
if (!entry || now > entry.resetAt) {
|
|
24773
|
+
this.buckets.set(key, { count: 1, resetAt: now + windowMs });
|
|
24774
|
+
return true;
|
|
24775
|
+
}
|
|
24776
|
+
if (entry.count >= limit) {
|
|
24777
|
+
return false;
|
|
24778
|
+
}
|
|
24779
|
+
entry.count += 1;
|
|
24780
|
+
return true;
|
|
24781
|
+
}
|
|
24782
|
+
/**
|
|
24783
|
+
* 清理过期桶
|
|
24784
|
+
*/
|
|
24785
|
+
cleanup() {
|
|
24786
|
+
const now = Date.now();
|
|
24787
|
+
for (const [k, v] of this.buckets) {
|
|
24788
|
+
if (now > v.resetAt) {
|
|
24789
|
+
this.buckets.delete(k);
|
|
24790
|
+
}
|
|
24791
|
+
}
|
|
24792
|
+
}
|
|
24793
|
+
};
|
|
24794
|
+
var rateLimiter = new PluginRateLimiter();
|
|
24795
|
+
setInterval(() => rateLimiter.cleanup(), 5 * 60 * 1e3).unref();
|
|
24796
|
+
var StreamConnectionTracker = class {
|
|
24797
|
+
constructor() {
|
|
24798
|
+
this.ipCounts = /* @__PURE__ */ new Map();
|
|
24799
|
+
this.pluginCounts = /* @__PURE__ */ new Map();
|
|
24800
|
+
}
|
|
24801
|
+
/**
|
|
24802
|
+
* 尝试获取流连接名额
|
|
24803
|
+
*/
|
|
24804
|
+
tryAcquire(ip, pluginId, maxIp = 5, maxPlugin = 50) {
|
|
24805
|
+
const curIp = this.ipCounts.get(ip) || 0;
|
|
24806
|
+
if (curIp >= maxIp) {
|
|
24807
|
+
return { success: false, reason: `Too many concurrent stream connections for this IP (max ${maxIp})` };
|
|
24808
|
+
}
|
|
24809
|
+
const curPlugin = this.pluginCounts.get(pluginId) || 0;
|
|
24810
|
+
if (curPlugin >= maxPlugin) {
|
|
24811
|
+
return { success: false, reason: `Too many concurrent stream connections for plugin (max ${maxPlugin})` };
|
|
24812
|
+
}
|
|
24813
|
+
this.ipCounts.set(ip, curIp + 1);
|
|
24814
|
+
this.pluginCounts.set(pluginId, curPlugin + 1);
|
|
24815
|
+
return { success: true };
|
|
24816
|
+
}
|
|
24817
|
+
/**
|
|
24818
|
+
* 释放流连接名额
|
|
24819
|
+
*/
|
|
24820
|
+
release(ip, pluginId) {
|
|
24821
|
+
const curIp = this.ipCounts.get(ip) || 0;
|
|
24822
|
+
if (curIp <= 1) {
|
|
24823
|
+
this.ipCounts.delete(ip);
|
|
24824
|
+
} else {
|
|
24825
|
+
this.ipCounts.set(ip, curIp - 1);
|
|
24826
|
+
}
|
|
24827
|
+
const curPlugin = this.pluginCounts.get(pluginId) || 0;
|
|
24828
|
+
if (curPlugin <= 1) {
|
|
24829
|
+
this.pluginCounts.delete(pluginId);
|
|
24830
|
+
} else {
|
|
24831
|
+
this.pluginCounts.set(pluginId, curPlugin - 1);
|
|
24832
|
+
}
|
|
24833
|
+
}
|
|
24834
|
+
};
|
|
24835
|
+
var streamConnectionTracker = new StreamConnectionTracker();
|
|
24836
|
+
function resolveAuthContext(req) {
|
|
24837
|
+
const actorId = getActorId(req);
|
|
24838
|
+
let token = getCookieToken(req);
|
|
24839
|
+
if (!token && req.headers.authorization?.startsWith("Bearer ")) {
|
|
24840
|
+
token = req.headers.authorization.slice(7).trim();
|
|
24841
|
+
}
|
|
24842
|
+
if (!token) {
|
|
24843
|
+
return {
|
|
24844
|
+
actorId,
|
|
24845
|
+
role: "anonymous",
|
|
24846
|
+
isAuthenticated: false
|
|
24847
|
+
};
|
|
24848
|
+
}
|
|
24849
|
+
const session = getValidSession(token);
|
|
24850
|
+
if (!session) {
|
|
24851
|
+
return {
|
|
24852
|
+
actorId: "anonymous",
|
|
24853
|
+
role: "anonymous",
|
|
24854
|
+
isAuthenticated: false
|
|
24855
|
+
};
|
|
24856
|
+
}
|
|
24857
|
+
let userRole = session.subRole || session.role || "anonymous";
|
|
24858
|
+
if (session.username === "admin" || session.userId === "usr_admin" || userRole === "admin") {
|
|
24859
|
+
userRole = "administrator";
|
|
24860
|
+
}
|
|
24861
|
+
return {
|
|
24862
|
+
actorId,
|
|
24863
|
+
userId: session.userId,
|
|
24864
|
+
username: session.username,
|
|
24865
|
+
role: userRole,
|
|
24866
|
+
permissions: session.permissions,
|
|
24867
|
+
isAuthenticated: true
|
|
24868
|
+
};
|
|
24869
|
+
}
|
|
24870
|
+
async function pluginApiGatewayMiddleware(req, res, next) {
|
|
24871
|
+
const pluginId = req.params.pluginId;
|
|
24872
|
+
const rawSubPath = req.params[0] || "";
|
|
24873
|
+
if (RESERVED_ACTIONS.has(rawSubPath)) {
|
|
24874
|
+
return next();
|
|
24875
|
+
}
|
|
24876
|
+
if (rawSubPath.includes("..") || rawSubPath.toLowerCase().includes("%2e%2e")) {
|
|
24877
|
+
res.status(400).json({ success: false, error: "Path traversal attempt detected" });
|
|
24878
|
+
return;
|
|
24879
|
+
}
|
|
24880
|
+
const normalizedSubPath = import_node_path3.default.posix.normalize("/" + rawSubPath);
|
|
24881
|
+
if (normalizedSubPath.includes("..")) {
|
|
24882
|
+
res.status(400).json({ success: false, error: "Path traversal attempt detected" });
|
|
24883
|
+
return;
|
|
24884
|
+
}
|
|
24885
|
+
const pluginHost = kernelContainer.pluginHost;
|
|
24886
|
+
if (!pluginHost) {
|
|
24887
|
+
res.status(503).json({ success: false, error: "Plugin system is not initialized" });
|
|
24888
|
+
return;
|
|
24889
|
+
}
|
|
24890
|
+
const resolvedUuid = pluginHost.resolvePluginUuid(pluginId);
|
|
24891
|
+
const manifest = pluginHost.getPluginManifest(pluginId);
|
|
24892
|
+
const contentLength = parseInt(req.headers["content-length"] || "0", 10);
|
|
24893
|
+
if (contentLength > MAX_PAYLOAD_BYTES) {
|
|
24894
|
+
res.status(413).json({
|
|
24895
|
+
success: false,
|
|
24896
|
+
error: `Payload too large. Maximum allowed size is ${MAX_PAYLOAD_BYTES / 1024}KB`
|
|
24897
|
+
});
|
|
24898
|
+
return;
|
|
24899
|
+
}
|
|
24900
|
+
const auth = resolveAuthContext(req);
|
|
24901
|
+
const method = req.method.toUpperCase();
|
|
24902
|
+
const manifestRoutes = manifest?.api?.routes || [];
|
|
24903
|
+
let matchedRule;
|
|
24904
|
+
for (const r of manifestRoutes) {
|
|
24905
|
+
if (r.method.toUpperCase() === method) {
|
|
24906
|
+
const compiled = compileRoutePattern(r.path);
|
|
24907
|
+
if (compiled.regex.test(normalizedSubPath)) {
|
|
24908
|
+
matchedRule = r;
|
|
24909
|
+
break;
|
|
24910
|
+
}
|
|
24911
|
+
}
|
|
24912
|
+
}
|
|
24913
|
+
const requiresAuth = matchedRule?.auth !== false;
|
|
24914
|
+
if (requiresAuth && !auth.isAuthenticated) {
|
|
24915
|
+
res.status(401).json({ success: false, error: "Authentication required" });
|
|
24916
|
+
return;
|
|
24917
|
+
}
|
|
24918
|
+
if (matchedRule?.roles && matchedRule.roles.length > 0) {
|
|
24919
|
+
const isSuperAdmin = auth.role === "administrator";
|
|
24920
|
+
const hasRole = matchedRule.roles.includes(auth.role);
|
|
24921
|
+
if (!isSuperAdmin && !hasRole) {
|
|
24922
|
+
res.status(403).json({
|
|
24923
|
+
success: false,
|
|
24924
|
+
error: `Forbidden. Required roles: ${matchedRule.roles.join(", ")}`
|
|
24925
|
+
});
|
|
24926
|
+
return;
|
|
24927
|
+
}
|
|
24928
|
+
}
|
|
24929
|
+
const clientIp = req.ip || req.socket?.remoteAddress || "unknown-ip";
|
|
24930
|
+
const rateLimitKey = `${clientIp}:${resolvedUuid || pluginId}:${method}:${normalizedSubPath}`;
|
|
24931
|
+
const windowMs = matchedRule?.rateLimit?.windowMs ?? 60 * 1e3;
|
|
24932
|
+
const maxRequests = matchedRule?.rateLimit?.max ?? 120;
|
|
24933
|
+
if (!rateLimiter.consume(rateLimitKey, maxRequests, windowMs)) {
|
|
24934
|
+
res.setHeader("Retry-After", Math.ceil(windowMs / 1e3).toString());
|
|
24935
|
+
res.status(429).json({
|
|
24936
|
+
success: false,
|
|
24937
|
+
error: "Too many requests, please slow down"
|
|
24938
|
+
});
|
|
24939
|
+
return;
|
|
24940
|
+
}
|
|
24941
|
+
const safeHeaders = {};
|
|
24942
|
+
const ALLOWED_REQUEST_HEADERS = [
|
|
24943
|
+
"content-type",
|
|
24944
|
+
"accept",
|
|
24945
|
+
"user-agent",
|
|
24946
|
+
"x-request-id",
|
|
24947
|
+
"accept-language"
|
|
24948
|
+
];
|
|
24949
|
+
for (const h of ALLOWED_REQUEST_HEADERS) {
|
|
24950
|
+
const val = req.headers[h];
|
|
24951
|
+
if (typeof val === "string") {
|
|
24952
|
+
safeHeaders[h] = val;
|
|
24953
|
+
}
|
|
24954
|
+
}
|
|
24955
|
+
const reqDto = {
|
|
24956
|
+
method,
|
|
24957
|
+
path: normalizedSubPath,
|
|
24958
|
+
params: {},
|
|
24959
|
+
// 由 PluginHttpRouter 在内部根据路由模板动态补全
|
|
24960
|
+
query: req.query,
|
|
24961
|
+
headers: safeHeaders,
|
|
24962
|
+
body: req.body,
|
|
24963
|
+
ip: clientIp,
|
|
24964
|
+
actor: {
|
|
24965
|
+
actorId: auth.actorId,
|
|
24966
|
+
userId: auth.userId,
|
|
24967
|
+
username: auth.username,
|
|
24968
|
+
role: auth.role,
|
|
24969
|
+
permissions: auth.permissions
|
|
24970
|
+
}
|
|
24971
|
+
};
|
|
24972
|
+
const isStream = matchedRule?.streaming === true || pluginHost.isStreamRoute(pluginId, method, normalizedSubPath) || req.headers.accept?.includes("text/event-stream") && pluginHost.isStreamRoute(pluginId, method, normalizedSubPath);
|
|
24973
|
+
if (isStream) {
|
|
24974
|
+
const maxIp = matchedRule?.rateLimit?.maxConcurrentStreams ?? 5;
|
|
24975
|
+
const acquireRes = streamConnectionTracker.tryAcquire(clientIp, resolvedUuid || pluginId, maxIp, 50);
|
|
24976
|
+
if (!acquireRes.success) {
|
|
24977
|
+
res.status(429).json({
|
|
24978
|
+
success: false,
|
|
24979
|
+
error: acquireRes.reason
|
|
24980
|
+
});
|
|
24981
|
+
return;
|
|
24982
|
+
}
|
|
24983
|
+
res.writeHead(200, {
|
|
24984
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
24985
|
+
"Cache-Control": "no-cache, no-transform",
|
|
24986
|
+
"Connection": "keep-alive",
|
|
24987
|
+
"X-Accel-Buffering": "no"
|
|
24988
|
+
});
|
|
24989
|
+
res.flushHeaders?.();
|
|
24990
|
+
let streamEnded = false;
|
|
24991
|
+
const closeListeners = /* @__PURE__ */ new Set();
|
|
24992
|
+
const releaseResource = () => {
|
|
24993
|
+
if (!streamEnded) {
|
|
24994
|
+
streamEnded = true;
|
|
24995
|
+
streamConnectionTracker.release(clientIp, resolvedUuid || pluginId);
|
|
24996
|
+
}
|
|
24997
|
+
};
|
|
24998
|
+
res.on("close", () => {
|
|
24999
|
+
releaseResource();
|
|
25000
|
+
for (const cb of closeListeners) {
|
|
25001
|
+
try {
|
|
25002
|
+
cb();
|
|
25003
|
+
} catch (e) {
|
|
25004
|
+
console.error("[PluginApiGateway] Error in stream onClose listener:", e);
|
|
25005
|
+
}
|
|
25006
|
+
}
|
|
25007
|
+
});
|
|
25008
|
+
const streamResponse = {
|
|
25009
|
+
get isClosed() {
|
|
25010
|
+
if (streamEnded || res.writableEnded || res.destroyed) return true;
|
|
25011
|
+
if (res.socket && !res.socket.writable) return true;
|
|
25012
|
+
return false;
|
|
25013
|
+
},
|
|
25014
|
+
write(data, event, id) {
|
|
25015
|
+
if (this.isClosed) return false;
|
|
25016
|
+
let sse = "";
|
|
25017
|
+
if (id) sse += `id: ${id}
|
|
25018
|
+
`;
|
|
25019
|
+
if (event) sse += `event: ${event}
|
|
25020
|
+
`;
|
|
25021
|
+
const text = typeof data === "object" ? JSON.stringify(data) : String(data);
|
|
25022
|
+
const lines = text.split("\n");
|
|
25023
|
+
for (const line of lines) {
|
|
25024
|
+
sse += `data: ${line}
|
|
25025
|
+
`;
|
|
25026
|
+
}
|
|
25027
|
+
sse += "\n";
|
|
25028
|
+
return res.write(sse);
|
|
25029
|
+
},
|
|
25030
|
+
end() {
|
|
25031
|
+
if (streamEnded) return;
|
|
25032
|
+
releaseResource();
|
|
25033
|
+
res.end();
|
|
25034
|
+
},
|
|
25035
|
+
error(err) {
|
|
25036
|
+
if (this.isClosed) return;
|
|
25037
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
25038
|
+
res.write(`event: error
|
|
25039
|
+
data: ${JSON.stringify({ error: msg })}
|
|
25040
|
+
|
|
25041
|
+
`);
|
|
25042
|
+
this.end();
|
|
25043
|
+
},
|
|
25044
|
+
onClose(callback) {
|
|
25045
|
+
if (this.isClosed) {
|
|
25046
|
+
try {
|
|
25047
|
+
callback();
|
|
25048
|
+
} catch {
|
|
25049
|
+
}
|
|
25050
|
+
} else {
|
|
25051
|
+
closeListeners.add(callback);
|
|
25052
|
+
}
|
|
25053
|
+
}
|
|
25054
|
+
};
|
|
25055
|
+
try {
|
|
25056
|
+
await pluginHost.dispatchHttpStream(pluginId, reqDto, streamResponse);
|
|
25057
|
+
} catch (err) {
|
|
25058
|
+
console.error(`[PluginApiGateway] Stream dispatch error for ${method} ${req.originalUrl}:`, err);
|
|
25059
|
+
if (!streamResponse.isClosed) {
|
|
25060
|
+
streamResponse.error(err);
|
|
25061
|
+
}
|
|
25062
|
+
}
|
|
25063
|
+
return;
|
|
25064
|
+
}
|
|
25065
|
+
try {
|
|
25066
|
+
const response = await pluginHost.dispatchHttpRequest(
|
|
25067
|
+
pluginId,
|
|
25068
|
+
reqDto,
|
|
25069
|
+
5e3
|
|
25070
|
+
);
|
|
25071
|
+
const status = typeof response.status === "number" && response.status >= 100 && response.status <= 599 ? response.status : 200;
|
|
25072
|
+
if (response.headers && typeof response.headers === "object") {
|
|
25073
|
+
for (const [k, v] of Object.entries(response.headers)) {
|
|
25074
|
+
const lowerKey = k.toLowerCase();
|
|
25075
|
+
if (!FORBIDDEN_RESPONSE_HEADERS.has(lowerKey)) {
|
|
25076
|
+
res.setHeader(k, v);
|
|
25077
|
+
}
|
|
25078
|
+
}
|
|
25079
|
+
}
|
|
25080
|
+
if (res.getHeader("content-type") === void 0) {
|
|
25081
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
25082
|
+
}
|
|
25083
|
+
res.status(status).send(response.body);
|
|
25084
|
+
} catch (err) {
|
|
25085
|
+
if (err.name === "GatewayTimeoutError" || err.message?.includes("timed out")) {
|
|
25086
|
+
res.status(504).json({ success: false, error: "Gateway Timeout: Plugin did not respond in time" });
|
|
25087
|
+
return;
|
|
25088
|
+
}
|
|
25089
|
+
console.error(`[PluginApiGateway] Error handling ${method} ${req.originalUrl}:`, err);
|
|
25090
|
+
sendSafeError(res, err);
|
|
25091
|
+
}
|
|
25092
|
+
}
|
|
25093
|
+
|
|
23892
25094
|
// server/routes/plugins.ts
|
|
23893
25095
|
function findLocalPluginSource(manifestId) {
|
|
23894
25096
|
const root = import_path14.default.resolve(process.cwd(), "v2_plugins");
|
|
@@ -24382,6 +25584,7 @@ function registerPluginsRoutes(ctx) {
|
|
|
24382
25584
|
sendSafeError(res, err);
|
|
24383
25585
|
}
|
|
24384
25586
|
});
|
|
25587
|
+
app.all("/api/plugins/:pluginId/*", pluginApiGatewayMiddleware);
|
|
24385
25588
|
app.get("/api/ai-providers", requireAuth("teacher", "administrator"), (req, res) => {
|
|
24386
25589
|
try {
|
|
24387
25590
|
const providers = kernelContainer.db.prepare("SELECT * FROM ai_providers ORDER BY created_at DESC").all();
|