webspresso 0.0.112 → 0.0.114

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 (42) hide show
  1. package/README.md +26 -0
  2. package/bin/commands/admin-password.js +1 -7
  3. package/bin/commands/api.js +1 -7
  4. package/bin/commands/new.js +1 -7
  5. package/bin/commands/page.js +1 -7
  6. package/bin/commands/seed.js +1 -7
  7. package/bin/commands/service.js +1 -7
  8. package/bin/utils/prompt.js +17 -0
  9. package/core/cache/tagged-lru.js +9 -3
  10. package/core/client/adapter/datastar.js +602 -26
  11. package/core/client/api.js +151 -7
  12. package/core/client/controller.js +172 -11
  13. package/core/client/devtool.js +59 -33
  14. package/core/client/forms.js +479 -29
  15. package/core/client/fragments.js +456 -27
  16. package/core/client/index.js +18 -1
  17. package/core/client/server-fragments.js +125 -40
  18. package/core/client/utils.js +72 -17
  19. package/core/console/commands.js +26 -5
  20. package/core/console/repl.js +7 -1
  21. package/core/http/pipeline.js +82 -1
  22. package/core/openapi/build-from-api-routes.js +5 -5
  23. package/core/orm/json-fields.js +7 -6
  24. package/core/orm/schema-helpers.js +18 -15
  25. package/core/orm/utils/nanoid.js +7 -8
  26. package/core/plugins/plugin-manager.js +155 -1
  27. package/core/services/validator.js +12 -1
  28. package/core/validation/apply.js +3 -0
  29. package/core/validation/index.js +25 -0
  30. package/core/validation/zod-introspect.js +187 -0
  31. package/index.d.ts +113 -1
  32. package/package.json +12 -11
  33. package/plugins/mcp/providers/services.js +2 -2
  34. package/src/api/define-api.js +2 -0
  35. package/src/api/validation-i18n.js +6 -2
  36. package/src/bundle/index.js +140 -6
  37. package/src/dev/api-typegen.js +79 -22
  38. package/src/dev/typegen.js +61 -35
  39. package/src/dev-reload/index.js +44 -2
  40. package/src/file-router.js +24 -6
  41. package/src/routing/mount-discovered-routes.js +24 -6
  42. package/src/server.js +59 -3
package/index.d.ts CHANGED
@@ -1919,6 +1919,9 @@ export interface PluginRegisterContext {
1919
1919
  addHelper(name: string, fn: (...args: unknown[]) => unknown): void;
1920
1920
  addFilter(name: string, fn: (...args: unknown[]) => unknown): void;
1921
1921
  addRoute(method: string, path: string, ...handlers: RequestHandler[]): void;
1922
+ registerClientController(name: string, filePath: string): void;
1923
+ registerClientService(name: string, filePath: string): void;
1924
+ registerFragment(name: string, fragmentDef: FragmentDefinition<any, any>): void;
1922
1925
  routes: unknown;
1923
1926
  [key: string]: unknown;
1924
1927
  }
@@ -1936,6 +1939,18 @@ export interface WebspressoPlugin {
1936
1939
  }
1937
1940
 
1938
1941
  export class PluginManager {
1942
+ registerClientController(name: string, filePath: string, pluginName?: string): void;
1943
+ registerClientService(name: string, filePath: string, pluginName?: string): void;
1944
+ registerFragment(name: string, fragmentDef: FragmentDefinition<any, any>, pluginName?: string): void;
1945
+ getClientControllers(): Array<{ name: string; filePath: string; pluginName: string }>;
1946
+ getClientServices(): Array<{ name: string; filePath: string; pluginName: string }>;
1947
+ getFragment(name: string): FragmentDefinition<any, any> | null;
1948
+ getFragments(): Record<string, FragmentDefinition<any, any>>;
1949
+ getClientManifest(): {
1950
+ controllers: Array<{ name: string; filePath: string; pluginName: string }>;
1951
+ services: Array<{ name: string; filePath: string; pluginName: string }>;
1952
+ fragments: string[];
1953
+ };
1939
1954
  [key: string]: unknown;
1940
1955
  }
1941
1956
 
@@ -5075,6 +5090,11 @@ export interface DefineApiOptions<
5075
5090
  middleware?: (string | import('express').RequestHandler | [string, Record<string, unknown>])[];
5076
5091
  flag?: string | { key: string; statusCode?: number; message?: string };
5077
5092
  schema?: TSchema;
5093
+ validateFields?: boolean | string[];
5094
+ validate?: (
5095
+ req: ApiRequest<TBody, TParams, TQuery, THeaders>,
5096
+ ctx: { body: TBody; fields: string[]; req: ApiRequest<TBody, TParams, TQuery, THeaders>; res: ApiResponse<TBody, TParams, TQuery, THeaders> }
5097
+ ) => Promise<Record<string, string> | null | undefined> | Record<string, string> | null | undefined;
5078
5098
  response?: Record<number | string, unknown>;
5079
5099
  handler: (
5080
5100
  req: ApiRequest<TBody, TParams, TQuery, THeaders>,
@@ -5094,6 +5114,16 @@ export function defineApi<
5094
5114
  >(
5095
5115
  config: {
5096
5116
  schema: TSchema;
5117
+ validateFields?: boolean | string[];
5118
+ validate?: (
5119
+ req: ApiRequest<ApiNoInfer<TBody>, ApiNoInfer<TParams>, ApiNoInfer<TQuery>, ApiNoInfer<THeaders>>,
5120
+ ctx: {
5121
+ body: ApiNoInfer<TBody>;
5122
+ fields: string[];
5123
+ req: ApiRequest<ApiNoInfer<TBody>, ApiNoInfer<TParams>, ApiNoInfer<TQuery>, ApiNoInfer<THeaders>>;
5124
+ res: ApiResponse<ApiNoInfer<TBody>, ApiNoInfer<TParams>, ApiNoInfer<TQuery>, ApiNoInfer<THeaders>>;
5125
+ }
5126
+ ) => Promise<Record<string, string> | null | undefined> | Record<string, string> | null | undefined;
5097
5127
  description?: string;
5098
5128
  tags?: string[];
5099
5129
  middleware?: (string | import('express').RequestHandler | [string, Record<string, unknown>])[];
@@ -6367,6 +6397,9 @@ export class ClientRequestError<TData = unknown> extends Error {
6367
6397
  readonly isTimedOut: boolean;
6368
6398
  readonly isDropped: boolean;
6369
6399
  readonly isSuperseded: boolean;
6400
+ readonly dispatched: boolean;
6401
+ readonly outcomeUnknown: boolean;
6402
+ readonly outcome: 'unknown' | 'failed' | 'not_sent';
6370
6403
  readonly cause?: unknown;
6371
6404
  constructor(options: {
6372
6405
  message: string;
@@ -6383,6 +6416,9 @@ export class ClientRequestError<TData = unknown> extends Error {
6383
6416
  isTimedOut?: boolean;
6384
6417
  isDropped?: boolean;
6385
6418
  isSuperseded?: boolean;
6419
+ dispatched?: boolean;
6420
+ outcomeUnknown?: boolean;
6421
+ outcome?: 'unknown' | 'failed' | 'not_sent';
6386
6422
  });
6387
6423
  }
6388
6424
 
@@ -6411,6 +6447,12 @@ export interface ClientRequestCallOptions<
6411
6447
  timeout?: number;
6412
6448
  retry?: boolean | number | ClientRetryOptions;
6413
6449
  retryMutations?: boolean;
6450
+ onUploadProgress?: (progress: {
6451
+ loaded: number;
6452
+ total: number;
6453
+ percent: number | null;
6454
+ lengthComputable: boolean;
6455
+ }) => void;
6414
6456
  }
6415
6457
 
6416
6458
  export interface AppApiRouteMap {
@@ -6440,6 +6482,8 @@ export interface ClientTaskOptions {
6440
6482
  concurrency?: ClientConcurrencyPolicy;
6441
6483
  policy?: ClientConcurrencyPolicy;
6442
6484
  key?: string;
6485
+ mutation?: boolean;
6486
+ kind?: 'query' | 'mutation';
6443
6487
  staleTime?: number | string;
6444
6488
  refetchOnMount?: ClientRefetchPolicy;
6445
6489
  refetchOnVisit?: ClientRefetchPolicy;
@@ -6714,6 +6758,8 @@ export interface ClientFormSubmitResult<TData = any> {
6714
6758
  hookError?: Error | null;
6715
6759
  error?: ClientRequestError | Error;
6716
6760
  aborted?: boolean;
6761
+ outcomeUnknown?: boolean;
6762
+ outcome?: 'unknown' | 'failed' | 'not_sent';
6717
6763
  }
6718
6764
 
6719
6765
  export interface ClientFormHookMeta<
@@ -6738,6 +6784,9 @@ export interface ClientFormHandle<
6738
6784
  readonly services: ClientServicesRegistry<TServices>;
6739
6785
  readonly pending: boolean;
6740
6786
  readonly dirty: boolean;
6787
+ readonly touched: boolean;
6788
+ readonly touchedFields: string[];
6789
+ readonly submitted: boolean;
6741
6790
  readonly inputRevision: number;
6742
6791
  readonly draftKey: string;
6743
6792
  readonly baseline: TValues;
@@ -6746,6 +6795,34 @@ export interface ClientFormHandle<
6746
6795
  readonly lastError: ClientRequestError | Error | null;
6747
6796
  readonly fieldErrors: Partial<Record<keyof TValues & string, string>> & Record<string, string>;
6748
6797
  disabled: boolean;
6798
+ isTouched(fieldName?: keyof TValues & string | string): boolean;
6799
+ touch(fieldName?: keyof TValues & string | string): this;
6800
+ untouch(fieldName?: keyof TValues & string | string): this;
6801
+ validateField(
6802
+ fieldName: (keyof TValues & string) | string,
6803
+ options?: { url?: string; method?: string; contextFields?: string[] | '*' }
6804
+ ): Promise<{
6805
+ ok: boolean;
6806
+ valid?: boolean;
6807
+ stale?: boolean;
6808
+ canceled?: boolean;
6809
+ networkError?: boolean;
6810
+ errors?: Record<string, string>;
6811
+ error?: Error;
6812
+ }>;
6813
+ readonly uploads: Array<{
6814
+ field: string;
6815
+ name: string;
6816
+ size: number;
6817
+ status: 'uploading' | 'done' | 'error' | 'canceled';
6818
+ loaded: number;
6819
+ total: number;
6820
+ percent: number | null;
6821
+ error: string | null;
6822
+ }>;
6823
+ cancelUpload(): boolean;
6824
+ createPreviewUrl(file: File | Blob, fieldName?: string): string | null;
6825
+ revokePreviewUrls(fieldName?: string): void;
6749
6826
  values(): TValues;
6750
6827
  read(submitter?: HTMLElement | null): TValues;
6751
6828
  fill(values: Partial<TValues> & Record<string, unknown>, options?: ClientFormFillOptions): this;
@@ -6801,10 +6878,12 @@ export interface ControllerFormsRegistry<TServices = AppClientServices> {
6801
6878
 
6802
6879
  export interface FragmentRefreshResult {
6803
6880
  ok: boolean;
6881
+ partial?: boolean;
6804
6882
  refreshed: string[];
6805
6883
  errors?: Error[];
6806
6884
  skipped?: boolean;
6807
6885
  canceled?: boolean;
6886
+ superseded?: boolean;
6808
6887
  error?: Error | null;
6809
6888
  timings?: {
6810
6889
  loadMs: number;
@@ -6816,6 +6895,12 @@ export interface FragmentRefreshOptions {
6816
6895
  replaceInput?: boolean;
6817
6896
  timeout?: number;
6818
6897
  throwOnError?: boolean;
6898
+ mode?: 'replace' | 'append' | 'prepend';
6899
+ limit?: number;
6900
+ target?: string;
6901
+ removeKeys?: string[];
6902
+ onBeforePatch?: (el: Element, inst: any) => void;
6903
+ onAfterPatch?: (el: Element, inst: any) => void;
6819
6904
  onSuccess?: (result: FragmentRefreshResult) => void | Promise<void>;
6820
6905
  onFailure?: (error: Error) => void | Promise<void>;
6821
6906
  onError?: (error: Error) => void | Promise<void>;
@@ -6825,6 +6910,7 @@ export interface FragmentRefreshOptions {
6825
6910
 
6826
6911
  export interface ControllerFragmentHandle<TInput extends Record<string, any> = Record<string, any>> {
6827
6912
  readonly input: TInput;
6913
+ readonly inputs: Record<string, TInput>;
6828
6914
  refresh(
6829
6915
  nextInput?: Partial<TInput> & Record<string, unknown>,
6830
6916
  options?: FragmentRefreshOptions
@@ -6836,10 +6922,17 @@ export interface ControllerErrorMeta {
6836
6922
  task?: string;
6837
6923
  event?: Event;
6838
6924
  actionEl?: HTMLElement | null;
6839
- phase?: 'action' | 'task' | 'connect' | 'disconnect';
6925
+ phase?: 'action' | 'task' | 'connect' | 'disconnect' | 'beforePatch' | 'afterPatch';
6840
6926
  [key: string]: unknown;
6841
6927
  }
6842
6928
 
6929
+ export interface FragmentPatchHookMeta {
6930
+ fragmentId?: string | null;
6931
+ fragmentName?: string | null;
6932
+ isFragmentRoot?: boolean;
6933
+ isDescendant?: boolean;
6934
+ }
6935
+
6843
6936
  export type WatchSource<T = any> =
6844
6937
  | (() => T)
6845
6938
  | string
@@ -6988,6 +7081,22 @@ export interface ControllerDefinition<
6988
7081
  TServices
6989
7082
  >
6990
7083
  ) => void;
7084
+ beforePatch?: (
7085
+ ctx: ControllerContext<
7086
+ [TState][TState extends any ? 0 : never] & { readonly [K in keyof TComputed]: ReturnType<TComputed[K]> },
7087
+ Record<string, ClientTask>,
7088
+ TServices
7089
+ >,
7090
+ meta: FragmentPatchHookMeta
7091
+ ) => void;
7092
+ afterPatch?: (
7093
+ ctx: ControllerContext<
7094
+ [TState][TState extends any ? 0 : never] & { readonly [K in keyof TComputed]: ReturnType<TComputed[K]> },
7095
+ Record<string, ClientTask>,
7096
+ TServices
7097
+ >,
7098
+ meta: FragmentPatchHookMeta
7099
+ ) => void | Promise<void>;
6991
7100
  onError?: ControllerErrorHandler<
6992
7101
  [TState][TState extends any ? 0 : never] & { readonly [K in keyof TComputed]: ReturnType<TComputed[K]> },
6993
7102
  Record<string, ClientTask>,
@@ -7052,6 +7161,9 @@ export interface FragmentDefinition<TInput = Record<string, unknown>, TData = Re
7052
7161
  refetchOnVisit?: ClientRefetchPolicy;
7053
7162
  refetchInterval?: number | string | false;
7054
7163
  refetchIntervalInBackground?: boolean;
7164
+ lazy?: boolean;
7165
+ placeholder?: string | ((ctx: { input: TInput; instanceId: string; name: string }) => string);
7166
+ placeholderTemplate?: string;
7055
7167
  load?: (context: FragmentLoaderContext<TInput>) => TData | Promise<TData>;
7056
7168
  }
7057
7169
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.112",
3
+ "version": "0.0.114",
4
4
  "description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -100,7 +100,7 @@
100
100
  "bench:ci:local": "npm run bench:baseline && npm run bench:ci",
101
101
  "bench:profile": "softscope --top 30 node scripts/profile-bench.mjs",
102
102
  "bench:profile:ai": "softscope --top 40 -o softscope-bench.ai node scripts/profile-bench.mjs",
103
- "check:types": "tsc --project tests/ts-smoke/tsconfig.json && tsc --noEmit --skipLibCheck false --types node index.d.ts",
103
+ "check:types": "tsc --project tests/ts-smoke/tsconfig.json && tsc --noEmit --skipLibCheck false --esModuleInterop --types node index.d.ts",
104
104
  "rebuild:native": "npm rebuild better-sqlite3 bcrypt sharp",
105
105
  "docs:dev": "docboot dev ./docs",
106
106
  "docs:build": "docboot build ./docs",
@@ -134,21 +134,21 @@
134
134
  "views/partials/webspresso-client-runtime.njk"
135
135
  ],
136
136
  "dependencies": {
137
- "commander": "^11.1.0",
137
+ "commander": "^14.0.3",
138
138
  "cookie-parser": "^1.4.7",
139
139
  "csv-parse": "^7.0.2",
140
140
  "dayjs": "^1.11.19",
141
141
  "express": "^5.2.1",
142
142
  "express-session": "^1.18.0",
143
- "helmet": "^7.2.0",
144
- "inquirer": "^8.2.6",
143
+ "helmet": "^8.3.0",
144
+ "inquirer": "^13.4.3",
145
145
  "knex": "^3.1.0",
146
146
  "multer": "^2.4.0",
147
147
  "nunjucks": "^3.2.4",
148
148
  "qs": "^6.17.1",
149
149
  "sanitize-html": "^2.18.1",
150
150
  "yaml": "^2.8.3",
151
- "zod": "^3.23.0",
151
+ "zod": "^4.6.7",
152
152
  "zod-to-json-schema": "^3.25.2"
153
153
  },
154
154
  "peerDependencies": {
@@ -218,7 +218,7 @@
218
218
  "@swup/scripts-plugin": "^2.1.0",
219
219
  "@types/express": "^5.0.0",
220
220
  "@types/node": "^20.14.0",
221
- "@vitest/coverage-v8": "^4.1.11",
221
+ "@vitest/coverage-v8": "^4.1.12",
222
222
  "bcrypt": "^6.0.0",
223
223
  "better-sqlite3": "^11.10.0",
224
224
  "chokidar": "^4.0.3",
@@ -226,7 +226,7 @@
226
226
  "dotenv": "^16.3.1",
227
227
  "exceljs": "^4.4.0",
228
228
  "express-rate-limit": "^8.4.1",
229
- "hono": "^4.13.13",
229
+ "hono": "^4.13.16",
230
230
  "mjml": "^5.4.0",
231
231
  "nodemailer": "^10.0.10",
232
232
  "release-it": "^20.2.1",
@@ -235,14 +235,14 @@
235
235
  "supertest": "^6.3.4",
236
236
  "swup": "^4.8.3",
237
237
  "typescript": "~5.6.3",
238
- "vitest": "^4.1.11",
238
+ "vitest": "^4.1.12",
239
239
  "vitest-doctor": "^0.3.0"
240
240
  },
241
241
  "engines": {
242
242
  "node": ">=20.0.0"
243
243
  },
244
244
  "overrides": {
245
- "basic-ftp": "^6.2.1",
245
+ "basic-ftp": "^6.2.4",
246
246
  "chokidar": "^4.0.3",
247
247
  "katex": "^0.18.2",
248
248
  "tar": "^7.5.11",
@@ -251,7 +251,8 @@
251
251
  "js-yaml": "^5.4.2",
252
252
  "qs": "^6.17.1",
253
253
  "sanitize-html": "^2.18.1",
254
- "vite": "^8.3.5"
254
+ "vite": "^8.3.5",
255
+ "minimatch@>=7.0.0 <=11.0.0": "^11.0.8"
255
256
  },
256
257
  "allowScripts": {
257
258
  "bcrypt@6.0.0": true,
@@ -4,7 +4,7 @@
4
4
  * @module plugins/mcp/providers/services
5
5
  */
6
6
 
7
- const { zodToJsonSchema } = require('zod-to-json-schema');
7
+ const { zodToJsonSchemaCompat } = require('../../../core/validation/zod-introspect');
8
8
 
9
9
  /**
10
10
  * Normalizes a dot-separated service name to an MCP-safe tool name
@@ -99,7 +99,7 @@ function convertZodToInputSchema(schema) {
99
99
  }
100
100
 
101
101
  try {
102
- const rawSchema = zodToJsonSchema(schema, {
102
+ const rawSchema = zodToJsonSchemaCompat(schema, {
103
103
  $refStrategy: 'none',
104
104
  removeAdditionalStrategy: 'strict',
105
105
  });
@@ -13,6 +13,8 @@
13
13
  * @param {Array<string|Function>} [config.middleware] - Named or functional middlewares
14
14
  * @param {string|{ key: string, statusCode?: number, message?: string }} [config.flag] - Feature flag required to access this endpoint
15
15
  * @param {Object|Function} [config.schema] - Request schemas (params, query, body, headers)
16
+ * @param {boolean|string[]} [config.validateFields] - Opt-in field-level validation without running mutation handler
17
+ * @param {Function} [config.validate] - Optional async read-only validation hook: async (ctx, fields) => ({ field: 'msg' })
16
18
  * @param {Object} [config.response] - Expected response schemas by status code
17
19
  * @param {Function} config.handler - Request handler: async (req, ctx) => data
18
20
  * @returns {Object}
@@ -94,8 +94,12 @@ function translateSingleIssue(issue, t) {
94
94
 
95
95
  const pathKey = Array.isArray(issue.path) && issue.path.length > 0 ? issue.path.join('.') : '';
96
96
  const field = Array.isArray(issue.path) && issue.path.length > 0 ? issue.path[issue.path.length - 1] : '';
97
- const isRequired = (issue.code === 'invalid_type' && (issue.received === 'undefined' || issue.message === 'Required'))
98
- || issue.message === 'Required';
97
+ const isRequired = (issue.code === 'invalid_type' && (
98
+ issue.received === 'undefined' ||
99
+ issue.input === undefined ||
100
+ issue.message === 'Required' ||
101
+ /received undefined$/i.test(issue.message || '')
102
+ )) || issue.message === 'Required';
99
103
 
100
104
  const params = Object.assign(
101
105
  {
@@ -213,22 +213,52 @@ function findPageEntryPoints(cwd = process.cwd()) {
213
213
  walkViews(dirToScan);
214
214
  }
215
215
 
216
+ // 4. Plugin-registered client controllers and services (if PluginManager is active)
217
+ try {
218
+ const { getPluginManager } = require('../../core/plugins/plugin-manager');
219
+ const pm = typeof getPluginManager === 'function' ? getPluginManager() : null;
220
+ if (pm) {
221
+ if (typeof pm.getClientControllers === 'function') {
222
+ for (const ctrl of pm.getClientControllers()) {
223
+ const entryName = `plugins/${ctrl.pluginName}/${ctrl.name}`;
224
+ if (!pageEntries[entryName]) {
225
+ pageEntries[entryName] = path.isAbsolute(ctrl.filePath)
226
+ ? ctrl.filePath
227
+ : path.resolve(cwd, ctrl.filePath);
228
+ }
229
+ }
230
+ }
231
+ if (typeof pm.getClientServices === 'function') {
232
+ for (const svc of pm.getClientServices()) {
233
+ const entryName = `plugins/${svc.pluginName}/services/${svc.name}`;
234
+ if (!pageEntries[entryName]) {
235
+ pageEntries[entryName] = path.isAbsolute(svc.filePath)
236
+ ? svc.filePath
237
+ : path.resolve(cwd, svc.filePath);
238
+ }
239
+ }
240
+ }
241
+ }
242
+ } catch (_e) {}
243
+
216
244
  return pageEntries;
217
245
  }
218
246
 
219
247
  /**
220
- * Discovers all `.client.{js,ts,mjs,jsx,tsx}` controller files across `pages/` and `views/`
248
+ * Discovers all `.client.{js,ts,mjs,jsx,tsx}` controller files across `pages/`, `views/`, and plugins
221
249
  * and validates deterministic controller names for collisions.
222
250
  *
223
251
  * @param {string} [cwd=process.cwd()]
224
- * @returns {Array<{ controllerName: string, entryName: string, filePath: string, relativePath: string }>}
252
+ * @param {object} [options]
253
+ * @returns {Array<{ controllerName: string, entryName: string, filePath: string, relativePath: string, pluginName?: string }>}
225
254
  */
226
- function discoverClientControllers(cwd = process.cwd()) {
255
+ function discoverClientControllers(cwd = process.cwd(), options = {}) {
227
256
  const pageEntries = findPageEntryPoints(cwd);
228
257
  const discovered = [];
229
258
  const byControllerName = new Map();
230
259
 
231
260
  for (const [entryName, filePath] of Object.entries(pageEntries)) {
261
+ if (entryName.startsWith('plugins/')) continue;
232
262
  const relativePath = path.relative(cwd, filePath).replace(/\\/g, '/');
233
263
  const controllerName = deriveControllerNameFromPath(relativePath);
234
264
  if (!controllerName) continue;
@@ -256,6 +286,46 @@ function discoverClientControllers(cwd = process.cwd()) {
256
286
  discovered.push(record);
257
287
  }
258
288
 
289
+ try {
290
+ const pm =
291
+ options.pluginManager ||
292
+ require('../../core/plugins/plugin-manager').getPluginManager();
293
+ if (pm && typeof pm.getClientControllers === 'function') {
294
+ for (const ctrl of pm.getClientControllers()) {
295
+ const absPath = path.isAbsolute(ctrl.filePath)
296
+ ? ctrl.filePath
297
+ : path.resolve(cwd, ctrl.filePath);
298
+ const relativePath = path.relative(cwd, absPath).replace(/\\/g, '/');
299
+ if (byControllerName.has(ctrl.name)) {
300
+ const existing = byControllerName.get(ctrl.name);
301
+ if (existing.filePath !== absPath) {
302
+ const err = new Error(
303
+ `Controller name collision: "${ctrl.name}" is defined by both "${existing.relativePath}" and "${relativePath}".`
304
+ );
305
+ err.code = 'CONTROLLER_NAME_COLLISION';
306
+ err.controllerName = ctrl.name;
307
+ err.files = [existing.relativePath, relativePath];
308
+ throw err;
309
+ }
310
+ continue;
311
+ }
312
+ const record = {
313
+ controllerName: ctrl.name,
314
+ entryName: `plugins/${ctrl.pluginName}/${ctrl.name}`,
315
+ filePath: absPath,
316
+ relativePath,
317
+ pluginName: ctrl.pluginName,
318
+ };
319
+ byControllerName.set(ctrl.name, record);
320
+ discovered.push(record);
321
+ }
322
+ }
323
+ } catch (err) {
324
+ if (err && err.code === 'CONTROLLER_NAME_COLLISION') {
325
+ throw err;
326
+ }
327
+ }
328
+
259
329
  return discovered;
260
330
  }
261
331
 
@@ -944,17 +1014,34 @@ function buildBundleFallback({ cwd, entryPoints, outdir, publicDir, manifestPath
944
1014
  const emittedChunksBySource = new Map(); // absSourcePath -> { absOut, relFromOutdir, relOutput }
945
1015
 
946
1016
  function transpileSource(absFile, rawCode) {
1017
+ let code = rawCode;
947
1018
  if ((absFile.endsWith('.ts') || absFile.endsWith('.tsx')) && ts) {
948
- const transpiled = ts.transpileModule(rawCode, {
1019
+ const transpiled = ts.transpileModule(code, {
949
1020
  compilerOptions: {
950
1021
  module: ts.ModuleKind.ESNext,
951
1022
  target: ts.ScriptTarget.ES2020,
952
1023
  },
953
1024
  fileName: absFile,
954
1025
  });
955
- return transpiled.outputText;
1026
+ code = transpiled.outputText;
1027
+ }
1028
+ return code.replace(/\bprocess\.env\.NODE_ENV\b/g, '"production"');
1029
+ }
1030
+
1031
+ function extractCjsExportNames(code) {
1032
+ const match = code.match(/module\.exports\s*=\s*\{([\s\S]*?)\};?\s*$/);
1033
+ if (!match) return [];
1034
+ const body = match[1].replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
1035
+ const names = new Set();
1036
+ const propRegex = /^\s*(?:get\s+|set\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:,|:|\()/gm;
1037
+ let m;
1038
+ while ((m = propRegex.exec(body)) !== null) {
1039
+ const name = m[1];
1040
+ if (name && name !== 'get' && name !== 'set' && name !== 'function') {
1041
+ names.add(name);
1042
+ }
956
1043
  }
957
- return rawCode;
1044
+ return Array.from(names);
958
1045
  }
959
1046
 
960
1047
  function emitChunkModule(absModulePath) {
@@ -1038,6 +1125,53 @@ function buildBundleFallback({ cwd, entryPoints, outdir, publicDir, manifestPath
1038
1125
  }
1039
1126
  );
1040
1127
 
1128
+ // 3. Transform CommonJS modules (`module.exports = ...` / `require('./...')`) into native ESM chunks
1129
+ if (/\bmodule\.exports\b/.test(rewritten)) {
1130
+ const cjsImports = [];
1131
+ let depSeq = 0;
1132
+ const depVarBySpec = new Map();
1133
+ const exportNames = extractCjsExportNames(rewritten);
1134
+
1135
+ rewritten = rewritten.replace(
1136
+ /\b(?:reqFn|require)\s*\(\s*(['"])(\.[^'"]+)\1\s*\)/g,
1137
+ (fullMatch, quote, spec) => {
1138
+ if (spec === './debug' || spec === './devtool' || spec === './server-fragments') {
1139
+ return '{}';
1140
+ }
1141
+ const resolved = resolveLocalModuleFile(importerAbsPath, spec);
1142
+ if (!resolved) return fullMatch;
1143
+ const chunkInfo = emitChunkModule(resolved);
1144
+ if (!chunkInfo || !chunkInfo.absOut) return fullMatch;
1145
+ let relSpec = path.relative(emitterOutDir, chunkInfo.absOut).replace(/\\/g, '/');
1146
+ if (!relSpec.startsWith('.')) relSpec = './' + relSpec;
1147
+ let depVar = depVarBySpec.get(relSpec);
1148
+ if (!depVar) {
1149
+ depVar = `__cjs_dep_${++depSeq}`;
1150
+ depVarBySpec.set(relSpec, depVar);
1151
+ cjsImports.push(`import ${depVar} from ${quote}${relSpec}${quote};`);
1152
+ collectedImports.push({ path: chunkInfo.relOutput, kind: 'import-statement' });
1153
+ }
1154
+ return depVar;
1155
+ }
1156
+ );
1157
+
1158
+ const strippedBody = rewritten.replace(/^\s*['"]use strict['"];?\s*/, '');
1159
+ const namedExportLines = exportNames.map(
1160
+ (name) => `export const ${name} = __cjs_exports.${name};`
1161
+ );
1162
+ rewritten = [
1163
+ ...cjsImports,
1164
+ 'const __cjs_exports = (() => {',
1165
+ 'const module = { exports: {} };',
1166
+ 'const exports = module.exports;',
1167
+ strippedBody,
1168
+ 'return module.exports;',
1169
+ '})();',
1170
+ 'export default __cjs_exports;',
1171
+ ...namedExportLines,
1172
+ ].join('\n');
1173
+ }
1174
+
1041
1175
  return rewritten;
1042
1176
  }
1043
1177