every-plugin 3.0.0-rc.0 → 3.0.0-rc.4

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 (84) hide show
  1. package/dist/client.cjs +69 -0
  2. package/dist/client.cjs.map +1 -0
  3. package/dist/client.d.cts +15 -0
  4. package/dist/client.d.cts.map +1 -0
  5. package/dist/client.d.mts +15 -0
  6. package/dist/client.d.mts.map +1 -0
  7. package/dist/client.mjs +68 -0
  8. package/dist/client.mjs.map +1 -0
  9. package/dist/dev/host.cjs +149 -0
  10. package/dist/dev/host.cjs.map +1 -0
  11. package/dist/dev/host.d.cts +32 -0
  12. package/dist/dev/host.d.cts.map +1 -0
  13. package/dist/dev/host.d.mts +32 -0
  14. package/dist/dev/host.d.mts.map +1 -0
  15. package/dist/dev/host.mjs +148 -0
  16. package/dist/dev/host.mjs.map +1 -0
  17. package/dist/dev/serve.cjs +14 -109
  18. package/dist/dev/serve.cjs.map +1 -1
  19. package/dist/dev/serve.d.cts.map +1 -1
  20. package/dist/dev/serve.d.mts.map +1 -1
  21. package/dist/dev/serve.mjs +14 -109
  22. package/dist/dev/serve.mjs.map +1 -1
  23. package/dist/errors.cjs +1 -0
  24. package/dist/errors.cjs.map +1 -1
  25. package/dist/errors.d.cts +2 -2
  26. package/dist/errors.d.mts +2 -2
  27. package/dist/errors.mjs +2 -2
  28. package/dist/errors.mjs.map +1 -1
  29. package/dist/index.cjs +3 -0
  30. package/dist/index.d.cts +5 -4
  31. package/dist/index.d.mts +5 -4
  32. package/dist/index.mjs +3 -2
  33. package/dist/plugin.cjs +1 -0
  34. package/dist/plugin.cjs.map +1 -1
  35. package/dist/plugin.d.cts +17 -3
  36. package/dist/plugin.d.cts.map +1 -1
  37. package/dist/plugin.d.mts +16 -2
  38. package/dist/plugin.d.mts.map +1 -1
  39. package/dist/plugin.mjs +1 -0
  40. package/dist/plugin.mjs.map +1 -1
  41. package/dist/runtime/errors.cjs +20 -0
  42. package/dist/runtime/errors.cjs.map +1 -1
  43. package/dist/runtime/errors.d.cts +23 -1
  44. package/dist/runtime/errors.d.cts.map +1 -1
  45. package/dist/runtime/errors.d.mts +23 -1
  46. package/dist/runtime/errors.d.mts.map +1 -1
  47. package/dist/runtime/errors.mjs +20 -1
  48. package/dist/runtime/errors.mjs.map +1 -1
  49. package/dist/runtime/mf-config.cjs +1 -1
  50. package/dist/runtime/mf-config.mjs +1 -1
  51. package/dist/runtime/services/module-federation.service.cjs +1 -7
  52. package/dist/runtime/services/module-federation.service.cjs.map +1 -1
  53. package/dist/runtime/services/module-federation.service.mjs +1 -7
  54. package/dist/runtime/services/module-federation.service.mjs.map +1 -1
  55. package/dist/runtime/services/plugin-loader.service.cjs +26 -1
  56. package/dist/runtime/services/plugin-loader.service.cjs.map +1 -1
  57. package/dist/runtime/services/plugin-loader.service.d.cts +2 -2
  58. package/dist/runtime/services/plugin-loader.service.mjs +26 -1
  59. package/dist/runtime/services/plugin-loader.service.mjs.map +1 -1
  60. package/dist/runtime/services/shared-identity.cjs +1 -1
  61. package/dist/runtime/services/shared-identity.cjs.map +1 -1
  62. package/dist/runtime/services/shared-identity.mjs +1 -1
  63. package/dist/runtime/services/shared-identity.mjs.map +1 -1
  64. package/dist/types.d.cts +26 -2
  65. package/dist/types.d.cts.map +1 -1
  66. package/dist/types.d.mts +25 -1
  67. package/dist/types.d.mts.map +1 -1
  68. package/dist/version-manifest.cjs +1 -1
  69. package/dist/version-manifest.cjs.map +1 -1
  70. package/dist/version-manifest.mjs +1 -1
  71. package/dist/version-manifest.mjs.map +1 -1
  72. package/package.json +19 -2
  73. package/src/client.ts +111 -0
  74. package/src/dev/host.ts +235 -0
  75. package/src/dev/serve.ts +14 -135
  76. package/src/errors.ts +2 -1
  77. package/src/index.ts +9 -0
  78. package/src/plugin.ts +20 -1
  79. package/src/runtime/errors.ts +44 -0
  80. package/src/runtime/services/module-federation.service.ts +4 -7
  81. package/src/runtime/services/plugin-loader.service.ts +32 -0
  82. package/src/runtime/services/shared-identity.ts +1 -1
  83. package/src/types.ts +39 -2
  84. package/src/version-manifest.ts +1 -1
@@ -0,0 +1,235 @@
1
+ import type http from "node:http";
2
+ import { createServer } from "node:http";
3
+
4
+ const corsHeaders = {
5
+ "Access-Control-Allow-Origin": "*",
6
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
7
+ "Access-Control-Allow-Headers": "X-Requested-With, content-type, Authorization",
8
+ };
9
+
10
+ const applyCorsHeaders = (res: http.ServerResponse) => {
11
+ Object.entries(corsHeaders).forEach(([key, value]) => {
12
+ res.setHeader(key, value);
13
+ });
14
+ };
15
+
16
+ const sendJson = (res: http.ServerResponse, status: number, body: unknown) => {
17
+ res.statusCode = status;
18
+ res.setHeader("content-type", "application/json; charset=utf-8");
19
+ res.end(JSON.stringify(body));
20
+ };
21
+
22
+ const sendText = (res: http.ServerResponse, status: number, text: string) => {
23
+ res.statusCode = status;
24
+ res.setHeader("content-type", "text/plain; charset=utf-8");
25
+ res.end(text);
26
+ };
27
+
28
+ const pathnameOf = (req: http.IncomingMessage): string =>
29
+ ((req.url ?? "/").split("?")[0] ?? "/").replace(/\/+$/, "") || "/";
30
+
31
+ const toWebRequest = (req: http.IncomingMessage, port: number): Request => {
32
+ const url = `http://localhost:${port}${req.url}`;
33
+ return new Request(url, {
34
+ method: req.method,
35
+ headers: req.headers,
36
+ body: req.method !== "GET" && req.method !== "HEAD" ? req : undefined,
37
+ duplex: req.method !== "GET" && req.method !== "HEAD" ? "half" : undefined,
38
+ } as RequestInit);
39
+ };
40
+
41
+ const buildHostContext = (
42
+ effectContext: unknown,
43
+ webRequest: Request,
44
+ cachedRawBodyRef: { value: string | null },
45
+ ) => {
46
+ const rawClone =
47
+ webRequest.method === "GET" || webRequest.method === "HEAD" ? null : webRequest.clone();
48
+
49
+ return {
50
+ reqHeaders: webRequest.headers,
51
+ "effect/context": effectContext,
52
+ getRawBody: async (): Promise<string> => {
53
+ if (cachedRawBodyRef.value !== null) return cachedRawBodyRef.value;
54
+ if (!rawClone) {
55
+ cachedRawBodyRef.value = "";
56
+ return cachedRawBodyRef.value;
57
+ }
58
+ cachedRawBodyRef.value = await rawClone.text();
59
+ return cachedRawBodyRef.value;
60
+ },
61
+ };
62
+ };
63
+
64
+ export interface DirectModuleHostHandler {
65
+ handle(
66
+ request: Request,
67
+ options: { prefix: string; context: unknown },
68
+ ): Promise<{ response?: Response }>;
69
+ }
70
+
71
+ const handleOrpc = async (
72
+ req: http.IncomingMessage,
73
+ res: http.ServerResponse,
74
+ handler: DirectModuleHostHandler,
75
+ prefix: string,
76
+ errorLabel: string,
77
+ effectContext: unknown,
78
+ port: number,
79
+ ) => {
80
+ try {
81
+ const webRequest = toWebRequest(req, port);
82
+ const cachedRawBodyRef = { value: null as string | null };
83
+ const result = await handler.handle(webRequest, {
84
+ prefix,
85
+ context: buildHostContext(effectContext, webRequest, cachedRawBodyRef),
86
+ });
87
+
88
+ if (result.response) {
89
+ res.statusCode = result.response.status;
90
+ result.response.headers.forEach((value: string, key: string) => {
91
+ res.setHeader(key, value);
92
+ });
93
+ const text = await result.response.text();
94
+ if (!res.getHeader("content-length")) {
95
+ res.setHeader("content-length", Buffer.byteLength(text).toString());
96
+ }
97
+ res.end(text);
98
+ } else {
99
+ sendText(res, 404, "Not Found");
100
+ }
101
+ } catch (error) {
102
+ console.error(`${errorLabel} error:`, error);
103
+ sendJson(res, 500, { error: (error as Error).message });
104
+ }
105
+ };
106
+
107
+ export interface DirectModuleHostHandlers {
108
+ rpc: DirectModuleHostHandler | null;
109
+ api: DirectModuleHostHandler | null;
110
+ }
111
+
112
+ export interface DirectModuleHostOptions {
113
+ name: string;
114
+ handlers: DirectModuleHostHandlers;
115
+ effectContext: unknown;
116
+ port?: number;
117
+ version?: string;
118
+ rpcPrefix?: `/${string}`;
119
+ apiPrefix?: `/${string}`;
120
+ staticHandler?:
121
+ | ((req: http.IncomingMessage, res: http.ServerResponse, next: () => void) => void)
122
+ | null;
123
+ }
124
+
125
+ export interface DirectModuleHostHandle {
126
+ port: number;
127
+ close: () => Promise<void>;
128
+ }
129
+
130
+ export async function startDirectModuleHost(
131
+ options: DirectModuleHostOptions,
132
+ ): Promise<DirectModuleHostHandle> {
133
+ const port = options.port ?? 0;
134
+ let resolvedPort = port;
135
+ const rpcPrefix = options.rpcPrefix ?? "/api/rpc";
136
+ const apiPrefix = options.apiPrefix ?? "/api";
137
+ const staticHandler = options.staticHandler ?? null;
138
+ const resolveEffectContext: () => unknown =
139
+ typeof options.effectContext === "function"
140
+ ? (options.effectContext as () => unknown)
141
+ : () => options.effectContext;
142
+
143
+ const server = createServer((req, res) => {
144
+ applyCorsHeaders(res);
145
+ const pathname = pathnameOf(req);
146
+
147
+ if (req.method === "OPTIONS") {
148
+ res.statusCode = 200;
149
+ res.end();
150
+ return;
151
+ }
152
+
153
+ if (pathname === "/" && (req.method === "GET" || req.method === "HEAD")) {
154
+ sendJson(res, 200, {
155
+ ok: true,
156
+ module: options.name,
157
+ version: options.version,
158
+ status: options.handlers.rpc ? "ready" : "loading",
159
+ endpoints: {
160
+ health: "/health",
161
+ docs: apiPrefix,
162
+ rpc: rpcPrefix,
163
+ },
164
+ });
165
+ return;
166
+ }
167
+
168
+ if (pathname === "/health" && (req.method === "GET" || req.method === "HEAD")) {
169
+ sendText(res, 200, "OK");
170
+ return;
171
+ }
172
+
173
+ if (pathname.startsWith(`${rpcPrefix}/`)) {
174
+ if (!options.handlers.rpc) {
175
+ sendJson(res, 503, { error: "Plugin still loading..." });
176
+ return;
177
+ }
178
+ handleOrpc(
179
+ req,
180
+ res,
181
+ options.handlers.rpc,
182
+ rpcPrefix,
183
+ "RPC",
184
+ resolveEffectContext(),
185
+ resolvedPort,
186
+ ).catch(() => {});
187
+ return;
188
+ }
189
+
190
+ if (pathname === apiPrefix || pathname.startsWith(`${apiPrefix}/`)) {
191
+ if (!options.handlers.api) {
192
+ sendJson(res, 503, { error: "Plugin still loading..." });
193
+ return;
194
+ }
195
+ handleOrpc(
196
+ req,
197
+ res,
198
+ options.handlers.api,
199
+ apiPrefix,
200
+ "OpenAPI",
201
+ resolveEffectContext(),
202
+ resolvedPort,
203
+ ).catch(() => {});
204
+ return;
205
+ }
206
+
207
+ if (staticHandler) {
208
+ staticHandler(req, res, () => {
209
+ sendText(res, 404, "Not Found");
210
+ });
211
+ return;
212
+ }
213
+
214
+ sendText(res, 404, "Not Found");
215
+ });
216
+
217
+ await new Promise<void>((resolve, reject) => {
218
+ server.once("error", reject);
219
+ server.listen(port, () => resolve());
220
+ });
221
+
222
+ const address = server.address();
223
+ if (!address || typeof address === "string") {
224
+ await new Promise<void>((resolve) => server.close(() => resolve()));
225
+ throw new Error("Failed to resolve host port");
226
+ }
227
+ resolvedPort = address.port;
228
+
229
+ return {
230
+ port: resolvedPort,
231
+ close: async () => {
232
+ await new Promise<void>((resolve) => server.close(() => resolve()));
233
+ },
234
+ };
235
+ }
package/src/dev/serve.ts CHANGED
@@ -10,6 +10,7 @@ import { getPluginInfo, loadDevConfig } from "../build/rspack/utils";
10
10
  import { ensureGeneratedUiRsbuildConfig, pluginLayoutKey } from "../build/ui/generated-config";
11
11
  import { PLUGIN_ERROR_STATUS_MAP } from "../errors";
12
12
  import { loadRemoteWithRetry } from "../remote-entry";
13
+ import { startDirectModuleHost } from "./host";
13
14
  import { killChildEscalating, watchParentDeath } from "./watch-kill";
14
15
 
15
16
  const corsHeaders = {
@@ -81,12 +82,6 @@ const loadPluginWithRetry = (
81
82
  }),
82
83
  );
83
84
 
84
- const sendJson = (res: http.ServerResponse, status: number, body: unknown) => {
85
- res.statusCode = status;
86
- res.setHeader("content-type", "application/json; charset=utf-8");
87
- res.end(JSON.stringify(body));
88
- };
89
-
90
85
  const sendText = (res: http.ServerResponse, status: number, text: string) => {
91
86
  res.statusCode = status;
92
87
  res.setHeader("content-type", "text/plain; charset=utf-8");
@@ -125,7 +120,6 @@ export async function startPluginDevServer(
125
120
  const compositionKey = pluginLayoutKey(cwd) ?? pluginId;
126
121
  const port = options.port ?? (Number(process.env.PORT) || devConfig?.port || 3999);
127
122
  const rpcPrefix = normalizePrefix(devConfig?.prefix);
128
- const rpcBase = `/api/rpc${rpcPrefix}`;
129
123
 
130
124
  const distDir = path.join(cwd, "dist");
131
125
  const serveStatic = sirv(distDir, { dev: true });
@@ -208,134 +202,19 @@ export async function startPluginDevServer(
208
202
  const handlers: { rpc: any; api: any } = { rpc: null, api: null };
209
203
  const effectContextHolder: { context: unknown } = { context: null };
210
204
  let runtime: { shutdown: () => Promise<void> } | null = null;
211
-
212
- const pathnameOf = (req: http.IncomingMessage): string =>
213
- ((req.url ?? "/").split("?")[0] ?? "/").replace(/\/+$/, "") || "/";
214
-
215
- const buildDevContext = (_req: http.IncomingMessage, webRequest: Request) => {
216
- const rawClone =
217
- webRequest.method === "GET" || webRequest.method === "HEAD" ? null : webRequest.clone();
218
- let cachedRawBody: string | null = null;
219
- return {
220
- reqHeaders: webRequest.headers,
221
- "effect/context": effectContextHolder.context,
222
- getRawBody: async (): Promise<string> => {
223
- if (cachedRawBody !== null) return cachedRawBody;
224
- if (!rawClone) {
225
- cachedRawBody = "";
226
- return cachedRawBody;
227
- }
228
- cachedRawBody = await rawClone.text();
229
- return cachedRawBody;
230
- },
231
- };
232
- };
233
-
234
- const toWebRequest = (req: http.IncomingMessage): Request => {
235
- const url = `http://localhost:${port}${req.url}`;
236
- return new Request(url, {
237
- method: req.method,
238
- headers: req.headers,
239
- body: req.method !== "GET" && req.method !== "HEAD" ? req : undefined,
240
- duplex: req.method !== "GET" && req.method !== "HEAD" ? "half" : undefined,
241
- } as RequestInit);
242
- };
243
-
244
- const handleOrpc = async (
245
- req: http.IncomingMessage,
246
- res: http.ServerResponse,
247
- handler: any,
248
- prefix: string,
249
- errorLabel: string,
250
- ) => {
251
- try {
252
- const webRequest = toWebRequest(req);
253
- const result = await handler.handle(webRequest, {
254
- prefix,
255
- context: buildDevContext(req, webRequest),
256
- });
257
-
258
- if (result.response) {
259
- res.statusCode = result.response.status;
260
- result.response.headers.forEach((value: string, key: string) => {
261
- res.setHeader(key, value);
262
- });
263
- const text = await result.response.text();
264
- if (!res.getHeader("content-length")) {
265
- res.setHeader("content-length", Buffer.byteLength(text).toString());
266
- }
267
- res.end(text);
268
- } else {
269
- sendText(res, 404, "Not Found");
270
- }
271
- } catch (error) {
272
- console.error(`${errorLabel} error:`, error);
273
- sendJson(res, 500, { error: (error as Error).message });
274
- }
275
- };
276
-
277
- const server = http.createServer((req, res) => {
278
- applyCorsHeaders(res);
279
- const pathname = pathnameOf(req);
280
-
281
- if (req.method === "OPTIONS") {
282
- res.statusCode = 200;
283
- res.end();
284
- return;
285
- }
286
-
287
- if (pathname === "/" && (req.method === "GET" || req.method === "HEAD")) {
288
- sendJson(res, 200, {
289
- ok: true,
290
- plugin: pluginInfo.normalizedName,
291
- version: pluginInfo.version,
292
- status: handlers.rpc ? "ready" : "loading",
293
- endpoints: {
294
- health: "/",
295
- docs: "/api",
296
- rpc: rpcBase,
297
- },
298
- });
299
- return;
300
- }
301
-
302
- if (pathname === "/health" && (req.method === "GET" || req.method === "HEAD")) {
303
- sendText(res, 200, "OK");
304
- return;
305
- }
306
-
307
- if (pathname.startsWith(`${rpcBase}/`)) {
308
- if (!handlers.rpc) {
309
- sendJson(res, 503, { error: "Plugin still loading..." });
310
- return;
311
- }
312
- handleOrpc(req, res, handlers.rpc, rpcBase, "RPC").catch(() => {});
313
- return;
314
- }
315
-
316
- if (pathname === "/api" || pathname.startsWith("/api/")) {
317
- if (!handlers.api) {
318
- sendJson(res, 503, { error: "Plugin still loading..." });
319
- return;
320
- }
321
- handleOrpc(req, res, handlers.api, "/api", "OpenAPI").catch(() => {});
322
- return;
323
- }
324
-
325
- serveStatic(req, res, () => {
326
- sendText(res, 404, "Not Found");
327
- });
328
- });
329
-
330
- await new Promise<void>((resolve, reject) => {
331
- server.once("error", reject);
332
- server.listen(port, () => resolve());
205
+ const host = await startDirectModuleHost({
206
+ name: pluginInfo.normalizedName,
207
+ version: pluginInfo.version,
208
+ handlers,
209
+ effectContext: () => effectContextHolder.context,
210
+ port,
211
+ staticHandler: serveStatic,
333
212
  });
334
213
 
335
- console.log(`🚀 ${pluginInfo.name} dev server listening on port ${port}`);
336
- console.log(`├─ 📡 RPC: http://localhost:${port}/api/rpc${rpcPrefix}`);
337
- console.log(`├─ 📖 Docs: http://localhost:${port}/api`);
338
- console.log(`└─ 💚 Health: http://localhost:${port}/`);
214
+ console.log(`🚀 ${pluginInfo.name} dev server listening on port ${host.port}`);
215
+ console.log(`├─ 📡 RPC: http://localhost:${host.port}/api/rpc${rpcPrefix}`);
216
+ console.log(`├─ 📖 Docs: http://localhost:${host.port}/api`);
217
+ console.log(`└─ 💚 Health: http://localhost:${host.port}/`);
339
218
 
340
219
  const load = async () => {
341
220
  try {
@@ -360,7 +239,7 @@ export async function startPluginDevServer(
360
239
 
361
240
  const registry: Record<string, { remote: string }> = {
362
241
  [pluginId]: {
363
- remote: `http://localhost:${port}/${DEV_ENTRY_FILENAME}`,
242
+ remote: `http://localhost:${host.port}/${DEV_ENTRY_FILENAME}`,
364
243
  },
365
244
  ...siblings,
366
245
  };
@@ -440,8 +319,8 @@ export async function startPluginDevServer(
440
319
  if (uiStaticServer) {
441
320
  await new Promise<void>((resolve) => uiStaticServer.close(() => resolve()));
442
321
  }
322
+ await host.close();
443
323
  if (runtime) await runtime.shutdown().catch(() => {});
444
- await new Promise<void>((resolve) => server.close(() => resolve()));
445
324
  };
446
325
 
447
326
  process.once("SIGINT", async () => {
package/src/errors.ts CHANGED
@@ -125,9 +125,10 @@ export const PLUGIN_ERROR_STATUS_MAP: Record<string, number> = {
125
125
  CONNECTION_ERROR: 502,
126
126
  } as const;
127
127
 
128
- export type { PluginLoadFailureInfo } from "./runtime/errors";
128
+ export type { PluginLoadFailureInfo, PluginWarningInfo } from "./runtime/errors";
129
129
  export {
130
130
  classifyPluginFailure,
131
+ defaultMissingSecretWarnings,
131
132
  extractFromFiberFailure,
132
133
  formatORPCError,
133
134
  ModuleFederationError,
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import "@orpc/openapi/extensions/route";
2
2
 
3
+ export * from "./client";
3
4
  export * from "./effect-helpers";
4
5
  export * from "./errors";
5
6
  export * from "./plugin";
@@ -13,10 +14,16 @@ export type {
13
14
  EveryPlugin,
14
15
  InitializedPlugin,
15
16
  LoadedPlugin,
17
+ ModuleClientEntry,
18
+ ModuleEndpoint,
19
+ ModuleSurface,
20
+ ModuleTransport,
21
+ PluginClientEntry,
16
22
  PluginClientType,
17
23
  PluginConfigInput,
18
24
  PluginContext,
19
25
  PluginContract,
26
+ PluginEndpoint,
20
27
  PluginInstance,
21
28
  PluginMetadata,
22
29
  PluginRegistry,
@@ -24,6 +31,8 @@ export type {
24
31
  PluginRuntimeConfig,
25
32
  PluginSecrets,
26
33
  PluginServicesEntry,
34
+ PluginSurface,
35
+ PluginTransport,
27
36
  PluginVariables,
28
37
  RegisteredPlugins,
29
38
  SecretsConfig,
package/src/plugin.ts CHANGED
@@ -5,6 +5,7 @@ import type { ContractedRouter, Implementer } from "@orpc/server";
5
5
  import { implement } from "@orpc/server";
6
6
  import type { Scope } from "effect";
7
7
  import { Context, Effect, Layer } from "effect";
8
+ import type { PluginWarningInfo } from "./runtime/errors";
8
9
  import { type PluginRuntimeError, toPluginRuntimeError } from "./runtime/errors";
9
10
 
10
11
  type ContextOutput<T> = T extends AnySchema ? InferSchemaOutput<T> : Record<string, never>;
@@ -19,11 +20,20 @@ export type PluginConfigFor<
19
20
  context: TRequestContext;
20
21
  };
21
22
 
22
- type PluginInitializeInput<V extends AnySchema, S extends AnySchema> = {
23
+ export type PluginInitializeInput<V extends AnySchema, S extends AnySchema> = {
23
24
  variables: InferSchemaOutput<V>;
24
25
  secrets: InferSchemaOutput<S>;
25
26
  };
26
27
 
28
+ /**
29
+ * A plugin's own config-derived warnings, evaluated once with the validated
30
+ * config after initialization. Declaring `warnings` opts the plugin out of
31
+ * the loader's default missing-secret warning — these messages replace it.
32
+ */
33
+ export type PluginWarningsFn<V extends AnySchema, S extends AnySchema> = (
34
+ config: PluginInitializeInput<V, S>,
35
+ ) => PluginWarningInfo[] | string[];
36
+
27
37
  export class PluginIdTag extends Context.Service<PluginIdTag, string>()("PluginId") {}
28
38
 
29
39
  /**
@@ -50,6 +60,13 @@ type PluginDefinition<
50
60
  * module statically.
51
61
  */
52
62
  servicesTag?: Context.Key<any, any>;
63
+ /**
64
+ * Optional warnings function evaluated once with the validated config after
65
+ * initialization. Returns structured non-fatal warnings (e.g. an optional
66
+ * secret was not set, so the feature depending on it is disabled).
67
+ * Declaring it opts out of the loader's default missing-secret warning.
68
+ */
69
+ warnings?: PluginWarningsFn<V, S>;
53
70
  /**
54
71
  * Initialize the plugin by returning an Effect `Layer`.
55
72
  *
@@ -107,6 +124,7 @@ export interface Plugin<
107
124
  readonly contract: TContract;
108
125
  readonly configSchema: PluginConfigFor<TVariables, TSecrets, TRequestContext>;
109
126
  readonly servicesTag?: Context.Key<any, any>;
127
+ readonly warnings?: PluginWarningsFn<TVariables, TSecrets>;
110
128
 
111
129
  initialize(
112
130
  config: PluginInitializeInput<TVariables, TSecrets>,
@@ -156,6 +174,7 @@ export const createPlugin: CreatePluginFn = function createPlugin<
156
174
  readonly contract = config.contract;
157
175
  readonly configSchema = configSchema;
158
176
  readonly servicesTag = config.servicesTag;
177
+ readonly warnings = config.warnings;
159
178
 
160
179
  initialize(
161
180
  pluginConfig: PluginInitializeInput<V, S>,
@@ -282,6 +282,50 @@ export interface PluginLoadFailureInfo {
282
282
  dbUrlMasked?: string;
283
283
  }
284
284
 
285
+ /**
286
+ * Structured, serializable warning surfaced alongside plugin failures in
287
+ * health endpoints and dashboards. Warnings are non-fatal: the plugin loaded
288
+ * and initialized successfully, but runs with reduced functionality (e.g. an
289
+ * optional secret was not set, so the feature depending on it is disabled).
290
+ */
291
+ export interface PluginWarningInfo {
292
+ /** loader-side key (host entry key, dev plugin id) */
293
+ pluginKey: string;
294
+ message: string;
295
+ hint?: string;
296
+ }
297
+
298
+ /**
299
+ * The loader's default warning policy for declared-but-unset secrets: a
300
+ * successfully initialized plugin whose declared secret names resolved to
301
+ * nothing (missing or empty in the environment) gets one consolidated
302
+ * warning. `*_DATABASE_URL` keys are excluded — they have the shared-API-DB
303
+ * fallback, and a genuinely-missing DB URL fails initialization (a `failure`,
304
+ * not a warning). A plugin that declares its own `warnings` function opts out
305
+ * of this default.
306
+ */
307
+ export const defaultMissingSecretWarnings = (
308
+ pluginKey: string,
309
+ declaredKeys: readonly string[],
310
+ resolvedKeys: readonly string[],
311
+ ): PluginWarningInfo[] => {
312
+ const resolved = new Set(resolvedKeys);
313
+ const missing = declaredKeys.filter(
314
+ (key) => !resolved.has(key) && !key.endsWith("_DATABASE_URL"),
315
+ );
316
+ if (missing.length === 0) return [];
317
+ return [
318
+ {
319
+ pluginKey,
320
+ message:
321
+ missing.length === 1
322
+ ? `Declared secret ${missing[0]} is not set — features depending on it are disabled`
323
+ : `${missing.length} declared secrets are not set: ${missing.join(", ")} — features depending on them are disabled`,
324
+ hint: "Set the secret(s) in your environment (.env in development); see the plugin's config schema for what each enables",
325
+ },
326
+ ];
327
+ };
328
+
285
329
  const NETWORK_RETRYABLE_PATTERNS = ["ETIMEDOUT", "ECONNRESET", "timeout", "503", "429"] as const;
286
330
  const NETWORK_DEAD_PATTERNS = ["ECONNREFUSED", "ENOTFOUND", "EHOSTUNREACH"] as const;
287
331
  const RETRYABLE_ORPC_CODES = new Set([
@@ -111,13 +111,10 @@ const resolveRemoteSelfName = (pluginId: string, url: string) =>
111
111
  const memo = remoteSelfNames.get(memoKey);
112
112
  if (memo) return memo;
113
113
 
114
- let selfName: string | null = null;
115
- try {
116
- const manifest = yield* Effect.promise(() => fetchRemoteIdentityManifest(url));
117
- selfName = manifest?.metaData?.name?.trim() || null;
118
- } catch {
119
- selfName = null;
120
- }
114
+ const manifest = yield* Effect.tryPromise(() => fetchRemoteIdentityManifest(url)).pipe(
115
+ Effect.orElseSucceed(() => null),
116
+ );
117
+ const selfName = manifest?.metaData?.name?.trim() || null;
121
118
 
122
119
  const resolved = selfName ?? getNormalizedRemoteName(pluginId);
123
120
  remoteSelfNames.set(memoKey, resolved);
@@ -11,11 +11,39 @@ import type {
11
11
  PluginMetadata,
12
12
  PluginRegistry,
13
13
  } from "../../types";
14
+ import type { PluginWarningInfo } from "../errors";
14
15
  import { PluginRuntimeError, toPluginRuntimeError } from "../errors";
15
16
  import { validate } from "../validation";
16
17
  import { ModuleFederationService } from "./module-federation.service";
17
18
  import { SecretsService } from "./secrets.service";
18
19
 
20
+ /**
21
+ * Evaluate a plugin's `warnings` function against the validated config.
22
+ * Fail-safe: a throwing warnings function becomes a warning itself rather
23
+ * than failing initialization. Strings are normalized to `PluginWarningInfo`
24
+ * with the plugin's registry key.
25
+ */
26
+ const collectPluginWarnings = (
27
+ plugin: AnyPlugin,
28
+ config: { variables: unknown; secrets: unknown },
29
+ ): PluginWarningInfo[] => {
30
+ const warningsFn = plugin.warnings;
31
+ if (!warningsFn) return [];
32
+ try {
33
+ const raw = warningsFn(config as never);
34
+ return raw.map((warning) =>
35
+ typeof warning === "string" ? { pluginKey: plugin.id, message: warning } : warning,
36
+ );
37
+ } catch (error) {
38
+ return [
39
+ {
40
+ pluginKey: plugin.id,
41
+ message: `Plugin warnings check failed: ${error instanceof Error ? error.message : String(error)}`,
42
+ },
43
+ ];
44
+ }
45
+ };
46
+
19
47
  export class PluginRegistryTag extends Context.Service<PluginRegistryTag, PluginRegistry>()(
20
48
  "PluginRegistry",
21
49
  ) {}
@@ -313,6 +341,10 @@ export const PluginLoaderServiceDefault = Layer.effect(
313
341
  config: { variables: _variables, secrets: hydratedConfig.secrets },
314
342
  effectContext,
315
343
  scope,
344
+ warnings: collectPluginWarnings(plugin, {
345
+ variables: _variables,
346
+ secrets: hydratedConfig.secrets,
347
+ }),
316
348
  } satisfies InitializedPlugin<T>;
317
349
  }),
318
350
  };
@@ -19,7 +19,7 @@ function manifestUrl(remoteUrl: string): string {
19
19
 
20
20
  function normalizeBaseUrl(remoteUrl: string): string {
21
21
  const withoutHash = remoteUrl.split("#")[0] ?? remoteUrl;
22
- return withoutHash.replace(/\/(mf-manifest\.json|remoteEntry\.js)$/i, "");
22
+ return withoutHash.replace(/\/(mf-manifest\.json|remoteEntry(?:\.[^/]+)?\.js)$/i, "");
23
23
  }
24
24
 
25
25
  export function compareSharedIdentity(