openlearn-next 0.3.9 → 0.3.12

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 (34) hide show
  1. package/README.md +9 -5
  2. package/cli-cleaner.mjs +72 -27
  3. package/cli-data.mjs +10 -0
  4. package/cli-doctor.mjs +471 -12
  5. package/cli.mjs +282 -159
  6. package/dist/assets/{BatchPickerModal-C7hweQi7.js → BatchPickerModal-b8bsoxzd.js} +1 -1
  7. package/dist/assets/{ClassesView-f7h7q1me.js → ClassesView-3G9F2-T1.js} +1 -1
  8. package/dist/assets/{CloudDriveModal-y1cqaYjU.js → CloudDriveModal-Ruedbe3T.js} +1 -1
  9. package/dist/assets/{CourseManagement-Bwfdk7Hi.js → CourseManagement-DUvQsAJI.js} +1 -1
  10. package/dist/assets/{CourseWizardModal-DwKB919Y.js → CourseWizardModal-CtcOsfxX.js} +1 -1
  11. package/dist/assets/{Dashboard-7ElGQEot.js → Dashboard-DQFRN4SK.js} +1 -1
  12. package/dist/assets/{ExportWeightModal-Dor_2k5X.js → ExportWeightModal-BnAmAzm0.js} +1 -1
  13. package/dist/assets/{HelpView-Blq21AX9.js → HelpView-p-BdI2ec.js} +1 -1
  14. package/dist/assets/{ImportLessonsModal-BNb6POIc.js → ImportLessonsModal-C_0VI5zO.js} +1 -1
  15. package/dist/assets/{InteractiveCoursewareViewer-DZQNSStZ.js → InteractiveCoursewareViewer-Cp8ptdxK.js} +1 -1
  16. package/dist/assets/{InteractiveWhiteboard-B-6-PHls.js → InteractiveWhiteboard-CUI_ccOy.js} +1 -1
  17. package/dist/assets/{LazyCourseware-YQTwc8Xc.js → LazyCourseware-C0Nre0Ds.js} +1 -1
  18. package/dist/assets/{LazyWhiteboard-DNwlH8kJ.js → LazyWhiteboard-D8OvZiRS.js} +1 -1
  19. package/dist/assets/{LessonEditorView-Biw7dyfp.js → LessonEditorView-wYeBs0Ro.js} +1 -1
  20. package/dist/assets/{LiveClassroomView-D2ZyINSZ.js → LiveClassroomView-D9Qrc-Q0.js} +1 -1
  21. package/dist/assets/{NotificationDetailModal-TJSWdZgO.js → NotificationDetailModal-DRdt0tOJ.js} +1 -1
  22. package/dist/assets/{PluginView-B2m35ykl.js → PluginView-LMpjhq4N.js} +4 -4
  23. package/dist/assets/{QuizGeneratorModal-XNW646aZ.js → QuizGeneratorModal-BLKd1rAk.js} +1 -1
  24. package/dist/assets/{StudentAssignmentView-C7qnbBVV.js → StudentAssignmentView-DQGlFuWt.js} +1 -1
  25. package/dist/assets/{StudentLessonView-DItUU7Zd.js → StudentLessonView-BZ-dR871.js} +1 -1
  26. package/dist/assets/{StudentPreviewModal-CjxvwHUc.js → StudentPreviewModal-BgUVPr_t.js} +1 -1
  27. package/dist/assets/{StudentView-Bg6tAP5x.js → StudentView-Ci5S9iot.js} +1 -1
  28. package/dist/assets/{SystemResourceLibraryModal-Cf2XJ6sf.js → SystemResourceLibraryModal-DnA2Op8S.js} +1 -1
  29. package/dist/assets/{TeacherView-Baq66mut.js → TeacherView-_esXu640.js} +1 -1
  30. package/dist/assets/{index-KP_ep5bf.js → index-BNpZ_4yC.js} +1 -1
  31. package/dist/assets/{index-B7B4Vq2l.js → index-DMpWx3Re.js} +3 -3
  32. package/dist/index.html +1 -1
  33. package/dist/server.cjs +1249 -12
  34. package/package.json +2 -2
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 \u548C\u4E8B\u4EF6\u4EE3\u7406
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.9";
8991
+ var PLATFORM_VERSION = "0.3.12";
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) {
@@ -23733,6 +24580,7 @@ ${examsText}
23733
24580
  var import_express2 = __toESM(require("express"), 1);
23734
24581
  var import_path14 = __toESM(require("path"), 1);
23735
24582
  var import_fs13 = __toESM(require("fs"), 1);
24583
+ var import_semver3 = __toESM(require("semver"), 1);
23736
24584
 
23737
24585
  // server/services/version-fetcher.ts
23738
24586
  var import_child_process2 = require("child_process");
@@ -23888,7 +24736,382 @@ async function checkVersion(source, installedVersion) {
23888
24736
  }
23889
24737
  }
23890
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
+
23891
25094
  // server/routes/plugins.ts
25095
+ function findLocalPluginSource(manifestId) {
25096
+ const root = import_path14.default.resolve(process.cwd(), "v2_plugins");
25097
+ if (!import_fs13.default.existsSync(root)) return null;
25098
+ let best = null;
25099
+ try {
25100
+ for (const entry of import_fs13.default.readdirSync(root, { withFileTypes: true })) {
25101
+ if (!entry.isDirectory()) continue;
25102
+ const manifestPath = import_path14.default.join(root, entry.name, "manifest.json");
25103
+ if (!import_fs13.default.existsSync(manifestPath)) continue;
25104
+ try {
25105
+ const m = JSON.parse(import_fs13.default.readFileSync(manifestPath, "utf-8"));
25106
+ if (m?.id !== manifestId || !m?.version) continue;
25107
+ if (!best || import_semver3.default.gt(m.version, best.version)) best = { dir: entry.name, version: m.version };
25108
+ } catch {
25109
+ }
25110
+ }
25111
+ } catch {
25112
+ }
25113
+ return best;
25114
+ }
23892
25115
  function isSafeExternalUrl(urlStr) {
23893
25116
  try {
23894
25117
  const parsed = new URL(urlStr);
@@ -24040,6 +25263,20 @@ function registerPluginsRoutes(ctx) {
24040
25263
  }
24041
25264
  const src = manifest.updateSource;
24042
25265
  if (!src?.type || !src?.repo) {
25266
+ const local = findLocalPluginSource(manifest.id);
25267
+ if (local) {
25268
+ const installed = manifest.version || "0.0.0";
25269
+ const hasUpdate = import_semver3.default.gt(local.version, installed);
25270
+ return res.json({
25271
+ success: true,
25272
+ hasUpdate,
25273
+ installedVersion: installed,
25274
+ latestVersion: local.version,
25275
+ downloadUrl: null,
25276
+ updateSource: "local-v2_plugins",
25277
+ changelog: hasUpdate ? `\u672C\u5730\u6E90 v2_plugins/${local.dir} \u63D0\u4F9B\u65B0\u7248\u672C v${local.version}\uFF0C\u8BF7\u91CD\u65B0\u6784\u5EFA ZIP \u540E\u901A\u8FC7\u4E0A\u4F20\u66F4\u65B0` : void 0
25278
+ });
25279
+ }
24043
25280
  return res.json({ success: true, hasUpdate: false, message: "\u8BE5\u63D2\u4EF6\u672A\u58F0\u660E\u66F4\u65B0\u6E90" });
24044
25281
  }
24045
25282
  const result = await checkVersion(src, manifest.version || "0.0.0");
@@ -24084,12 +25321,11 @@ function registerPluginsRoutes(ctx) {
24084
25321
  });
24085
25322
  }
24086
25323
  } else {
24087
- const zipPath = import_path14.default.resolve(process.cwd(), "v2_plugins/research-workflow/aymwoo-plugin-research-workflow.zip");
24088
- if (import_fs13.default.existsSync(zipPath)) {
24089
- zipBuffer = import_fs13.default.readFileSync(zipPath);
24090
- } else {
24091
- return res.status(404).json({ success: false, error: "\u672A\u627E\u5230\u66F4\u65B0\u5B89\u88C5\u5305" });
24092
- }
25324
+ return res.status(400).json({
25325
+ success: false,
25326
+ error: "\u7F3A\u5C11 downloadUrl\uFF1A\u8BF7\u5148\u6267\u884C\u68C0\u67E5\u66F4\u65B0\u83B7\u53D6\u4E0B\u8F7D\u5730\u5740\uFF0C\u6216\u901A\u8FC7 ZIP \u4E0A\u4F20\u66F4\u65B0",
25327
+ fallbackToClient: true
25328
+ });
24093
25329
  }
24094
25330
  const result = await kernelContainer.pluginDistributionManager.updateFromZip(zipBuffer, {
24095
25331
  targetPluginId,
@@ -24348,6 +25584,7 @@ function registerPluginsRoutes(ctx) {
24348
25584
  sendSafeError(res, err);
24349
25585
  }
24350
25586
  });
25587
+ app.all("/api/plugins/:pluginId/*", pluginApiGatewayMiddleware);
24351
25588
  app.get("/api/ai-providers", requireAuth("teacher", "administrator"), (req, res) => {
24352
25589
  try {
24353
25590
  const providers = kernelContainer.db.prepare("SELECT * FROM ai_providers ORDER BY created_at DESC").all();