@webstir-io/webstir-backend 0.1.15 → 0.1.16

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 (123) hide show
  1. package/README.md +106 -79
  2. package/dist/add.d.ts +59 -0
  3. package/dist/add.js +626 -0
  4. package/dist/build/artifacts.d.ts +115 -1
  5. package/dist/build/artifacts.js +4 -4
  6. package/dist/build/entries.js +1 -1
  7. package/dist/build/pipeline.d.ts +33 -1
  8. package/dist/build/pipeline.js +307 -65
  9. package/dist/cache/diff.js +9 -8
  10. package/dist/cache/reporters.js +1 -1
  11. package/dist/deploy-cli.d.ts +2 -0
  12. package/dist/deploy-cli.js +86 -0
  13. package/dist/diagnostics/summary.js +2 -2
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +4 -0
  16. package/dist/manifest/pipeline.js +103 -32
  17. package/dist/provider.js +35 -17
  18. package/dist/runtime/bun.d.ts +51 -0
  19. package/dist/runtime/bun.js +499 -0
  20. package/dist/runtime/core.d.ts +141 -0
  21. package/dist/runtime/core.js +316 -0
  22. package/dist/runtime/deploy-backend.d.ts +20 -0
  23. package/dist/runtime/deploy-backend.js +175 -0
  24. package/dist/runtime/deploy-shared.d.ts +43 -0
  25. package/dist/runtime/deploy-shared.js +75 -0
  26. package/dist/runtime/deploy-static.d.ts +2 -0
  27. package/dist/runtime/deploy-static.js +161 -0
  28. package/dist/runtime/deploy.d.ts +3 -0
  29. package/dist/runtime/deploy.js +91 -0
  30. package/dist/runtime/forms.d.ts +73 -0
  31. package/dist/runtime/forms.js +236 -0
  32. package/dist/runtime/request-hooks.d.ts +47 -0
  33. package/dist/runtime/request-hooks.js +102 -0
  34. package/dist/runtime/session-metadata.d.ts +13 -0
  35. package/dist/runtime/session-metadata.js +98 -0
  36. package/dist/runtime/session-runtime.d.ts +28 -0
  37. package/dist/runtime/session-runtime.js +180 -0
  38. package/dist/runtime/session.d.ts +83 -0
  39. package/dist/runtime/session.js +396 -0
  40. package/dist/runtime/views.d.ts +74 -0
  41. package/dist/runtime/views.js +221 -0
  42. package/dist/scaffold/assets.js +25 -21
  43. package/dist/testing/context.js +1 -1
  44. package/dist/testing/index.d.ts +1 -1
  45. package/dist/testing/index.js +100 -56
  46. package/dist/utils/bun.d.ts +2 -0
  47. package/dist/utils/bun.js +13 -0
  48. package/dist/watch.d.ts +13 -1
  49. package/dist/watch.js +345 -97
  50. package/dist/workspace.d.ts +8 -0
  51. package/dist/workspace.js +44 -3
  52. package/package.json +49 -14
  53. package/scripts/publish.sh +2 -92
  54. package/scripts/smoke.mjs +282 -107
  55. package/scripts/update-contract.sh +12 -10
  56. package/src/add.ts +964 -0
  57. package/src/build/artifacts.ts +49 -46
  58. package/src/build/entries.ts +12 -12
  59. package/src/build/pipeline.ts +779 -403
  60. package/src/cache/diff.ts +111 -105
  61. package/src/cache/reporters.ts +26 -26
  62. package/src/deploy-cli.ts +111 -0
  63. package/src/diagnostics/summary.ts +28 -22
  64. package/src/index.ts +11 -0
  65. package/src/manifest/pipeline.ts +328 -215
  66. package/src/provider.ts +115 -98
  67. package/src/runtime/bun.ts +793 -0
  68. package/src/runtime/core.ts +598 -0
  69. package/src/runtime/deploy-backend.ts +239 -0
  70. package/src/runtime/deploy-shared.ts +136 -0
  71. package/src/runtime/deploy-static.ts +191 -0
  72. package/src/runtime/deploy.ts +143 -0
  73. package/src/runtime/forms.ts +364 -0
  74. package/src/runtime/request-hooks.ts +165 -0
  75. package/src/runtime/session-metadata.ts +135 -0
  76. package/src/runtime/session-runtime.ts +267 -0
  77. package/src/runtime/session.ts +642 -0
  78. package/src/runtime/views.ts +385 -0
  79. package/src/scaffold/assets.ts +77 -73
  80. package/src/testing/context.js +8 -9
  81. package/src/testing/context.ts +9 -9
  82. package/src/testing/index.d.ts +14 -3
  83. package/src/testing/index.js +254 -175
  84. package/src/testing/index.ts +298 -195
  85. package/src/testing/types.d.ts +18 -19
  86. package/src/testing/types.ts +18 -18
  87. package/src/utils/bun.ts +26 -0
  88. package/src/watch.ts +503 -99
  89. package/src/workspace.ts +59 -3
  90. package/templates/backend/.env.example +15 -0
  91. package/templates/backend/auth/adapter.ts +335 -36
  92. package/templates/backend/db/connection.ts +190 -65
  93. package/templates/backend/db/migrate.ts +149 -43
  94. package/templates/backend/db/types.d.ts +1 -1
  95. package/templates/backend/env.ts +132 -20
  96. package/templates/backend/functions/hello/index.ts +1 -2
  97. package/templates/backend/index.ts +15 -508
  98. package/templates/backend/jobs/nightly/index.ts +1 -1
  99. package/templates/backend/jobs/runtime.ts +24 -11
  100. package/templates/backend/jobs/scheduler.ts +208 -46
  101. package/templates/backend/module.ts +227 -13
  102. package/templates/backend/observability/logger.ts +2 -12
  103. package/templates/backend/observability/metrics.ts +8 -5
  104. package/templates/backend/session/sqlite.ts +152 -0
  105. package/templates/backend/session/store.ts +45 -0
  106. package/templates/backend/tsconfig.json +1 -1
  107. package/tests/add.test.js +327 -0
  108. package/tests/authAdapter.test.js +315 -0
  109. package/tests/bundlerParity.test.js +217 -0
  110. package/tests/cacheReporter.test.js +10 -10
  111. package/tests/dbConnection.test.js +209 -0
  112. package/tests/deploy.test.js +357 -0
  113. package/tests/envLoader.test.js +271 -17
  114. package/tests/integration.test.js +2432 -3
  115. package/tests/jobsScheduler.test.js +253 -0
  116. package/tests/manifest.test.js +287 -12
  117. package/tests/migrationRunner.test.js +249 -0
  118. package/tests/sessionScaffoldStore.test.js +752 -0
  119. package/tests/sessionStore.test.js +490 -0
  120. package/tests/testing.test.js +252 -0
  121. package/tests/watch.test.js +192 -32
  122. package/tsconfig.json +3 -10
  123. package/templates/backend/server/fastify.ts +0 -288
@@ -1,270 +1,383 @@
1
1
  import path from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
- import { readFile } from 'node:fs/promises';
4
3
  import { pathToFileURL } from 'node:url';
5
4
 
6
5
  import { moduleManifestSchema, CONTRACT_VERSION } from '@webstir-io/module-contract';
7
- import type { ModuleDefinition, ModuleDiagnostic, ModuleManifest } from '@webstir-io/module-contract';
6
+ import type {
7
+ ModuleDefinition,
8
+ ModuleDiagnostic,
9
+ ModuleManifest,
10
+ } from '@webstir-io/module-contract';
11
+
12
+ import { readTextFile } from '../utils/bun.js';
8
13
 
9
14
  interface WorkspacePackageJson {
10
- readonly name?: string;
11
- readonly version?: string;
12
- readonly webstir?: {
13
- readonly moduleManifest?: WorkspaceModuleConfig;
14
- };
15
+ readonly name?: string;
16
+ readonly version?: string;
17
+ readonly webstir?: {
18
+ readonly moduleManifest?: WorkspaceModuleConfig;
19
+ };
15
20
  }
16
21
 
17
22
  type WorkspaceModuleConfig = Partial<ModuleManifest> & {
18
- readonly contractVersion?: string;
19
- readonly capabilities?: ModuleManifest['capabilities'];
23
+ readonly contractVersion?: string;
24
+ readonly capabilities?: ModuleManifest['capabilities'];
20
25
  };
21
26
 
27
+ type ManifestRouteLike = { readonly method?: string; readonly path?: string };
28
+ type ManifestJobLike = { readonly name?: string; readonly schedule?: string | null };
29
+
22
30
  export interface LoadManifestOptions {
23
- readonly workspaceRoot: string;
24
- readonly buildRoot: string;
25
- readonly entryPoints: readonly string[];
26
- readonly diagnostics: ModuleDiagnostic[];
31
+ readonly workspaceRoot: string;
32
+ readonly buildRoot: string;
33
+ readonly entryPoints: readonly string[];
34
+ readonly diagnostics: ModuleDiagnostic[];
27
35
  }
28
36
 
29
- export async function loadBackendModuleManifest(options: LoadManifestOptions): Promise<ModuleManifest> {
30
- const { workspaceRoot, buildRoot, entryPoints, diagnostics } = options;
31
- const pkgPath = path.join(workspaceRoot, 'package.json');
32
- let workspacePackage: WorkspacePackageJson | undefined;
37
+ export async function loadBackendModuleManifest(
38
+ options: LoadManifestOptions,
39
+ ): Promise<ModuleManifest> {
40
+ const { workspaceRoot, buildRoot, entryPoints, diagnostics } = options;
41
+ const pkgPath = path.join(workspaceRoot, 'package.json');
42
+ let workspacePackage: WorkspacePackageJson | undefined;
33
43
 
34
- try {
35
- const raw = await readFile(pkgPath, 'utf8');
36
- workspacePackage = JSON.parse(raw) as WorkspacePackageJson;
37
- } catch (error) {
38
- const err = error as NodeJS.ErrnoException;
39
- // Missing package.json is expected in some temporary workspaces; avoid noisy warnings.
40
- if (err.code !== 'ENOENT') {
41
- diagnostics.push({
42
- severity: 'warn',
43
- message: `[webstir-backend] unable to read ${pkgPath}: ${err.message}. Using defaults.`
44
- });
45
- }
44
+ try {
45
+ const raw = await readTextFile(pkgPath);
46
+ workspacePackage = JSON.parse(raw) as WorkspacePackageJson;
47
+ } catch (error) {
48
+ const err = error as NodeJS.ErrnoException;
49
+ // Missing package.json is expected in some temporary workspaces; avoid noisy warnings.
50
+ if (err.code !== 'ENOENT') {
51
+ diagnostics.push({
52
+ severity: 'warn',
53
+ message: `[webstir-backend] unable to read ${pkgPath}: ${err.message}. Using defaults.`,
54
+ });
46
55
  }
56
+ }
47
57
 
48
- const moduleConfig = workspacePackage?.webstir?.moduleManifest ?? {};
49
-
50
- let manifestCandidate: ModuleManifest = {
51
- contractVersion: typeof moduleConfig.contractVersion === 'string' ? moduleConfig.contractVersion : CONTRACT_VERSION,
52
- name: typeof moduleConfig.name === 'string' ? moduleConfig.name : deriveModuleName(workspacePackage, workspaceRoot),
53
- version: typeof moduleConfig.version === 'string' ? moduleConfig.version : deriveModuleVersion(workspacePackage),
54
- kind: 'backend',
55
- capabilities: Array.isArray(moduleConfig.capabilities) ? moduleConfig.capabilities : [],
56
- routes: moduleConfig.routes ?? [],
57
- views: moduleConfig.views ?? [],
58
- jobs: moduleConfig.jobs ?? [],
59
- events: moduleConfig.events ?? [],
60
- services: moduleConfig.services ?? [],
61
- init: moduleConfig.init,
62
- dispose: moduleConfig.dispose
63
- };
58
+ const moduleConfig = workspacePackage?.webstir?.moduleManifest ?? {};
64
59
 
65
- const definition = await loadModuleDefinition(buildRoot, diagnostics);
66
- if (definition) {
67
- const definitionManifest = definition.manifest ?? ({} as ModuleManifest);
68
- const routesFromDefinition = definition.routes?.map((route) => route.definition);
69
- const viewsFromDefinition = definition.views?.map((view) => view.definition);
70
-
71
- const mergedCapabilities = Array.from(
72
- new Set([...(manifestCandidate.capabilities ?? []), ...(definitionManifest.capabilities ?? [])])
73
- );
74
-
75
- manifestCandidate = {
76
- ...manifestCandidate,
77
- ...definitionManifest,
78
- capabilities: mergedCapabilities,
79
- routes: routesFromDefinition ?? definitionManifest.routes ?? manifestCandidate.routes ?? [],
80
- views: viewsFromDefinition ?? definitionManifest.views ?? manifestCandidate.views ?? [],
81
- jobs: definitionManifest.jobs ?? manifestCandidate.jobs ?? [],
82
- events: definitionManifest.events ?? manifestCandidate.events ?? [],
83
- services: definitionManifest.services ?? manifestCandidate.services ?? [],
84
- init: definitionManifest.init ?? manifestCandidate.init,
85
- dispose: definitionManifest.dispose ?? manifestCandidate.dispose
86
- };
87
- }
60
+ let manifestCandidate: ModuleManifest = {
61
+ contractVersion:
62
+ typeof moduleConfig.contractVersion === 'string'
63
+ ? moduleConfig.contractVersion
64
+ : CONTRACT_VERSION,
65
+ name:
66
+ typeof moduleConfig.name === 'string'
67
+ ? moduleConfig.name
68
+ : deriveModuleName(workspacePackage, workspaceRoot),
69
+ version:
70
+ typeof moduleConfig.version === 'string'
71
+ ? moduleConfig.version
72
+ : deriveModuleVersion(workspacePackage),
73
+ kind: 'backend',
74
+ capabilities: Array.isArray(moduleConfig.capabilities) ? moduleConfig.capabilities : [],
75
+ routes: moduleConfig.routes ?? [],
76
+ views: moduleConfig.views ?? [],
77
+ jobs: moduleConfig.jobs ?? [],
78
+ events: moduleConfig.events ?? [],
79
+ services: moduleConfig.services ?? [],
80
+ init: moduleConfig.init,
81
+ dispose: moduleConfig.dispose,
82
+ };
88
83
 
89
- const validation = moduleManifestSchema.safeParse(manifestCandidate);
90
- if (!validation.success) {
91
- const problems = validation.error.issues
92
- .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
93
- .join('; ');
94
- diagnostics.push({
95
- severity: 'error',
96
- message: `[webstir-backend] module manifest validation failed (${problems}). Falling back to defaults.`
97
- });
98
- return {
99
- contractVersion: CONTRACT_VERSION,
100
- name: deriveModuleName(workspacePackage, workspaceRoot),
101
- version: deriveModuleVersion(workspacePackage),
102
- kind: 'backend',
103
- capabilities: [],
104
- routes: [],
105
- views: [],
106
- jobs: [],
107
- events: [],
108
- services: []
109
- };
110
- }
84
+ const definition = await loadModuleDefinition(buildRoot, diagnostics);
85
+ if (definition) {
86
+ const definitionManifest = definition.manifest ?? ({} as ModuleManifest);
87
+ const routesFromDefinition = definition.routes?.map((route) => route.definition);
88
+ const viewsFromDefinition = definition.views?.map((view) => view.definition);
111
89
 
112
- const manifest = validation.data;
90
+ const mergedCapabilities = Array.from(
91
+ new Set([
92
+ ...(manifestCandidate.capabilities ?? []),
93
+ ...(definitionManifest.capabilities ?? []),
94
+ ]),
95
+ );
113
96
 
114
- try {
115
- const normalizePath = (p: unknown) => {
116
- let s = typeof p === 'string' ? p : '';
117
- if (!s.startsWith('/')) s = '/' + s;
118
- s = s.replace(/\/+/, '/');
119
- if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1);
120
- return s;
121
- };
122
- const seen = new Map<string, number>();
123
- for (const r of manifest.routes ?? []) {
124
- const method = typeof (r as any).method === 'string' ? (r as any).method.toUpperCase() : '';
125
- const pathKey = normalizePath((r as any).path);
126
- const key = `${method} ${pathKey}`;
127
- seen.set(key, (seen.get(key) ?? 0) + 1);
128
- }
129
- const dups = Array.from(seen.entries()).filter(([, count]) => count > 1);
130
- if (dups.length > 0) {
131
- const list = dups.map(([k, c]) => `${k} (${c}x)`).join(', ');
132
- diagnostics.push({ severity: 'warn', message: `[webstir-backend] duplicate route definitions: ${list}` });
133
- }
134
- } catch {
135
- // best-effort only
136
- }
97
+ manifestCandidate = {
98
+ ...manifestCandidate,
99
+ ...definitionManifest,
100
+ capabilities: mergedCapabilities,
101
+ routes: mergeRouteDefinitions(
102
+ routesFromDefinition ?? definitionManifest.routes,
103
+ manifestCandidate.routes,
104
+ ),
105
+ views: viewsFromDefinition ?? definitionManifest.views ?? manifestCandidate.views ?? [],
106
+ jobs: mergeJobDefinitions(definitionManifest.jobs, manifestCandidate.jobs),
107
+ events: definitionManifest.events ?? manifestCandidate.events ?? [],
108
+ services: definitionManifest.services ?? manifestCandidate.services ?? [],
109
+ init: definitionManifest.init ?? manifestCandidate.init,
110
+ dispose: definitionManifest.dispose ?? manifestCandidate.dispose,
111
+ };
112
+ }
113
+
114
+ const validation = moduleManifestSchema.safeParse(manifestCandidate);
115
+ if (!validation.success) {
116
+ const problems = validation.error.issues
117
+ .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
118
+ .join('; ');
119
+ diagnostics.push({
120
+ severity: 'error',
121
+ message: `[webstir-backend] module manifest validation failed (${problems}). Falling back to defaults.`,
122
+ });
123
+ return {
124
+ contractVersion: CONTRACT_VERSION,
125
+ name: deriveModuleName(workspacePackage, workspaceRoot),
126
+ version: deriveModuleVersion(workspacePackage),
127
+ kind: 'backend',
128
+ capabilities: [],
129
+ routes: [],
130
+ views: [],
131
+ jobs: [],
132
+ events: [],
133
+ services: [],
134
+ };
135
+ }
136
+
137
+ const manifest = validation.data;
137
138
 
138
- if (manifest.routes?.length && entryPoints.length === 0) {
139
- diagnostics.push({
140
- severity: 'warn',
141
- message: '[webstir-backend] module manifest defines routes but no entry points were built. Ensure backend compilation produced handlers.'
142
- });
139
+ try {
140
+ const normalizePath = (p: unknown) => {
141
+ let s = typeof p === 'string' ? p : '';
142
+ if (!s.startsWith('/')) s = `/${s}`;
143
+ s = s.replace(/\/+/, '/');
144
+ if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1);
145
+ return s;
146
+ };
147
+ const seen = new Map<string, number>();
148
+ for (const route of (manifest.routes ?? []) as readonly ManifestRouteLike[]) {
149
+ const method = typeof route.method === 'string' ? route.method.toUpperCase() : '';
150
+ const pathKey = normalizePath(route.path);
151
+ const key = `${method} ${pathKey}`;
152
+ seen.set(key, (seen.get(key) ?? 0) + 1);
153
+ }
154
+ const dups = Array.from(seen.entries()).filter(([, count]) => count > 1);
155
+ if (dups.length > 0) {
156
+ const list = dups.map(([k, c]) => `${k} (${c}x)`).join(', ');
157
+ diagnostics.push({
158
+ severity: 'warn',
159
+ message: `[webstir-backend] duplicate route definitions: ${list}`,
160
+ });
143
161
  }
162
+ } catch {
163
+ // best-effort only
164
+ }
144
165
 
145
- try {
146
- const jobs = Array.isArray(manifest.jobs) ? manifest.jobs : [];
147
- const events = Array.isArray(manifest.events) ? manifest.events : [];
148
- const services = Array.isArray(manifest.services) ? manifest.services : [];
149
- if (jobs.length + events.length + services.length > 0) {
150
- diagnostics.push({
151
- severity: 'info',
152
- message: `[webstir-backend] manifest jobs=${jobs.length} events=${events.length} services=${services.length}`
153
- });
154
- }
155
-
156
- const noSchedule = jobs.filter((j: any) => j && typeof j.name === 'string' && (j.schedule === undefined || j.schedule === null));
157
- if (noSchedule.length > 0) {
158
- const MAX_LIST = 10;
159
- const names = noSchedule.map((j: any) => j.name).slice(0, MAX_LIST).join(', ');
160
- const omitted = noSchedule.length > MAX_LIST ? ` (+${noSchedule.length - MAX_LIST} more)` : '';
161
- diagnostics.push({
162
- severity: 'warn',
163
- message: `[webstir-backend] jobs without schedules: ${names}${omitted}`
164
- });
165
- }
166
- } catch {
167
- // best-effort only
166
+ if (manifest.routes?.length && entryPoints.length === 0) {
167
+ diagnostics.push({
168
+ severity: 'warn',
169
+ message:
170
+ '[webstir-backend] module manifest defines routes but no entry points were built. Ensure backend compilation produced handlers.',
171
+ });
172
+ }
173
+
174
+ try {
175
+ const jobs = Array.isArray(manifest.jobs) ? manifest.jobs : [];
176
+ const events = Array.isArray(manifest.events) ? manifest.events : [];
177
+ const services = Array.isArray(manifest.services) ? manifest.services : [];
178
+ if (jobs.length + events.length + services.length > 0) {
179
+ diagnostics.push({
180
+ severity: 'info',
181
+ message: `[webstir-backend] manifest jobs=${jobs.length} events=${events.length} services=${services.length}`,
182
+ });
168
183
  }
169
184
 
170
- try {
171
- const routes = Array.isArray(manifest.routes) ? manifest.routes.length : 0;
172
- const views = Array.isArray(manifest.views) ? manifest.views.length : 0;
173
- const caps = Array.isArray(manifest.capabilities) && manifest.capabilities.length > 0 ? ` [${manifest.capabilities.join(', ')}]` : '';
174
- diagnostics.push({ severity: 'info', message: `[webstir-backend] manifest routes=${routes} views=${views}${caps}` });
175
- } catch {
176
- // ignore
185
+ const noSchedule = (jobs as readonly ManifestJobLike[]).filter(
186
+ (job) =>
187
+ typeof job.name === 'string' && (job.schedule === undefined || job.schedule === null),
188
+ );
189
+ if (noSchedule.length > 0) {
190
+ const MAX_LIST = 10;
191
+ const names = noSchedule
192
+ .map((job) => job.name)
193
+ .slice(0, MAX_LIST)
194
+ .join(', ');
195
+ const omitted =
196
+ noSchedule.length > MAX_LIST ? ` (+${noSchedule.length - MAX_LIST} more)` : '';
197
+ diagnostics.push({
198
+ severity: 'warn',
199
+ message: `[webstir-backend] jobs without schedules: ${names}${omitted}`,
200
+ });
177
201
  }
202
+ } catch {
203
+ // best-effort only
204
+ }
178
205
 
179
- return manifest;
206
+ try {
207
+ const routes = Array.isArray(manifest.routes) ? manifest.routes.length : 0;
208
+ const views = Array.isArray(manifest.views) ? manifest.views.length : 0;
209
+ const caps =
210
+ Array.isArray(manifest.capabilities) && manifest.capabilities.length > 0
211
+ ? ` [${manifest.capabilities.join(', ')}]`
212
+ : '';
213
+ diagnostics.push({
214
+ severity: 'info',
215
+ message: `[webstir-backend] manifest routes=${routes} views=${views}${caps}`,
216
+ });
217
+ } catch {
218
+ // ignore
219
+ }
220
+
221
+ return manifest;
180
222
  }
181
223
 
182
224
  async function loadModuleDefinition(
183
- buildRoot: string,
184
- diagnostics: ModuleDiagnostic[]
225
+ buildRoot: string,
226
+ diagnostics: ModuleDiagnostic[],
185
227
  ): Promise<ModuleDefinition | undefined> {
186
- const candidates = [
187
- path.join(buildRoot, 'module.js'),
188
- path.join(buildRoot, 'module.mjs'),
189
- path.join(buildRoot, 'module/index.js'),
190
- path.join(buildRoot, 'module/index.mjs')
191
- ];
192
-
193
- for (const fullPath of candidates) {
194
- if (!existsSync(fullPath)) {
195
- continue;
196
- }
197
-
198
- try {
199
- const moduleUrl = `${pathToFileURL(fullPath).href}?t=${Date.now()}`;
200
- const imported = (await import(moduleUrl)) as Record<string, unknown>;
201
- const definitionCandidate = extractModuleDefinition(imported);
202
- if (isModuleDefinition(definitionCandidate)) {
203
- return definitionCandidate;
204
- }
205
- diagnostics.push({
206
- severity: 'warn',
207
- message: `[webstir-backend] module definition at ${fullPath} does not export a createModule() definition.`
208
- });
209
- } catch (error) {
210
- diagnostics.push({
211
- severity: 'warn',
212
- message: `[webstir-backend] failed to load module definition from ${fullPath}: ${(error as Error).message}`
213
- });
214
- }
228
+ const candidates = [
229
+ path.join(buildRoot, 'module.js'),
230
+ path.join(buildRoot, 'module.mjs'),
231
+ path.join(buildRoot, 'module/index.js'),
232
+ path.join(buildRoot, 'module/index.mjs'),
233
+ ];
234
+
235
+ for (const fullPath of candidates) {
236
+ if (!existsSync(fullPath)) {
237
+ continue;
215
238
  }
216
239
 
217
- return undefined;
240
+ try {
241
+ const moduleUrl = `${pathToFileURL(fullPath).href}?t=${Date.now()}`;
242
+ const imported = (await import(moduleUrl)) as Record<string, unknown>;
243
+ const definitionCandidate = extractModuleDefinition(imported);
244
+ if (isModuleDefinition(definitionCandidate)) {
245
+ return definitionCandidate;
246
+ }
247
+ diagnostics.push({
248
+ severity: 'warn',
249
+ message: `[webstir-backend] module definition at ${fullPath} does not export a createModule() definition.`,
250
+ });
251
+ } catch (error) {
252
+ diagnostics.push({
253
+ severity: 'warn',
254
+ message: `[webstir-backend] failed to load module definition from ${fullPath}: ${(error as Error).message}`,
255
+ });
256
+ }
257
+ }
258
+
259
+ return undefined;
218
260
  }
219
261
 
220
262
  function extractModuleDefinition(exports: Record<string, unknown>): unknown {
221
- const keys = ['module', 'moduleDefinition', 'default', 'backendModule'];
222
- for (const key of keys) {
223
- if (key in exports) {
224
- const value = exports[key as keyof typeof exports];
225
- if (value !== null && value !== undefined) {
226
- return value;
227
- }
228
- }
263
+ const keys = ['module', 'moduleDefinition', 'default', 'backendModule'];
264
+ for (const key of keys) {
265
+ if (key in exports) {
266
+ const value = exports[key as keyof typeof exports];
267
+ if (value !== null && value !== undefined) {
268
+ return value;
269
+ }
229
270
  }
230
- return undefined;
271
+ }
272
+ return undefined;
231
273
  }
232
274
 
233
275
  function isModuleDefinition(value: unknown): value is ModuleDefinition {
234
- return typeof value === 'object' && value !== null && 'manifest' in (value as Record<string, unknown>);
276
+ return (
277
+ typeof value === 'object' && value !== null && 'manifest' in (value as Record<string, unknown>)
278
+ );
235
279
  }
236
280
 
237
281
  function deriveModuleName(pkg: WorkspacePackageJson | undefined, workspaceRoot: string): string {
238
- if (typeof pkg?.webstir?.moduleManifest?.name === 'string' && pkg.webstir.moduleManifest.name.length > 0) {
239
- return pkg.webstir.moduleManifest.name;
240
- }
241
- if (typeof pkg?.name === 'string' && pkg.name.length > 0) {
242
- return pkg.name;
243
- }
244
- return `backend-module-${path.basename(workspaceRoot)}`;
282
+ if (
283
+ typeof pkg?.webstir?.moduleManifest?.name === 'string' &&
284
+ pkg.webstir.moduleManifest.name.length > 0
285
+ ) {
286
+ return pkg.webstir.moduleManifest.name;
287
+ }
288
+ if (typeof pkg?.name === 'string' && pkg.name.length > 0) {
289
+ return pkg.name;
290
+ }
291
+ return `backend-module-${path.basename(workspaceRoot)}`;
245
292
  }
246
293
 
247
294
  function deriveModuleVersion(pkg: WorkspacePackageJson | undefined): string {
248
- if (typeof pkg?.webstir?.moduleManifest?.version === 'string' && pkg.webstir.moduleManifest.version.length > 0) {
249
- return pkg.webstir.moduleManifest.version;
295
+ if (
296
+ typeof pkg?.webstir?.moduleManifest?.version === 'string' &&
297
+ pkg.webstir.moduleManifest.version.length > 0
298
+ ) {
299
+ return pkg.webstir.moduleManifest.version;
300
+ }
301
+ if (typeof pkg?.version === 'string' && pkg.version.length > 0) {
302
+ return pkg.version;
303
+ }
304
+ return '0.0.0';
305
+ }
306
+
307
+ function mergeRouteDefinitions(
308
+ definitionRoutes: ModuleManifest['routes'] | undefined,
309
+ packageRoutes: ModuleManifest['routes'] | undefined,
310
+ ): ModuleManifest['routes'] {
311
+ const merged = Array.isArray(definitionRoutes) ? [...definitionRoutes] : [];
312
+ const seen = new Set(merged.map((route) => getRouteKey(route)).filter(Boolean));
313
+
314
+ for (const route of packageRoutes ?? []) {
315
+ const key = getRouteKey(route);
316
+ if (!key || seen.has(key)) {
317
+ continue;
250
318
  }
251
- if (typeof pkg?.version === 'string' && pkg.version.length > 0) {
252
- return pkg.version;
319
+ merged.push(route);
320
+ seen.add(key);
321
+ }
322
+
323
+ return merged;
324
+ }
325
+
326
+ function mergeJobDefinitions(
327
+ definitionJobs: ModuleManifest['jobs'] | undefined,
328
+ packageJobs: ModuleManifest['jobs'] | undefined,
329
+ ): ModuleManifest['jobs'] {
330
+ const merged = Array.isArray(definitionJobs) ? [...definitionJobs] : [];
331
+ const seen = new Set(merged.map((job) => getJobKey(job)).filter(Boolean));
332
+
333
+ for (const job of packageJobs ?? []) {
334
+ const key = getJobKey(job);
335
+ if (!key || seen.has(key)) {
336
+ continue;
253
337
  }
254
- return '0.0.0';
338
+ merged.push(job);
339
+ seen.add(key);
340
+ }
341
+
342
+ return merged;
343
+ }
344
+
345
+ function getJobKey(job: ManifestJobLike | undefined): string | undefined {
346
+ return typeof job?.name === 'string' && job.name.length > 0 ? job.name : undefined;
347
+ }
348
+
349
+ function getRouteKey(route: ManifestRouteLike | undefined): string | undefined {
350
+ const method = typeof route?.method === 'string' ? route.method.toUpperCase() : '';
351
+ const routePath = normalizeRoutePath(route?.path);
352
+ if (!method || !routePath) {
353
+ return undefined;
354
+ }
355
+ return `${method} ${routePath}`;
356
+ }
357
+
358
+ function normalizeRoutePath(routePath: unknown): string | undefined {
359
+ if (typeof routePath !== 'string' || routePath.length === 0) {
360
+ return undefined;
361
+ }
362
+
363
+ let normalized = routePath;
364
+ if (!normalized.startsWith('/')) normalized = `/${normalized}`;
365
+ normalized = normalized.replace(/\/+/g, '/');
366
+ if (normalized.length > 1 && normalized.endsWith('/')) normalized = normalized.slice(0, -1);
367
+ return normalized;
255
368
  }
256
369
 
257
370
  export async function summarizeBuiltManifest(
258
- buildRoot: string
371
+ buildRoot: string,
259
372
  ): Promise<{ routes: number; views: number; capabilities?: readonly string[] } | undefined> {
260
- const definition = await loadModuleDefinition(buildRoot, []);
261
- if (!definition || !definition.manifest) {
262
- return undefined;
263
- }
264
- const manifest = definition.manifest;
265
- return {
266
- routes: Array.isArray(manifest.routes) ? manifest.routes.length : 0,
267
- views: Array.isArray(manifest.views) ? manifest.views.length : 0,
268
- capabilities: manifest.capabilities
269
- };
373
+ const definition = await loadModuleDefinition(buildRoot, []);
374
+ if (!definition || !definition.manifest) {
375
+ return undefined;
376
+ }
377
+ const manifest = definition.manifest;
378
+ return {
379
+ routes: Array.isArray(manifest.routes) ? manifest.routes.length : 0,
380
+ views: Array.isArray(manifest.views) ? manifest.views.length : 0,
381
+ capabilities: manifest.capabilities,
382
+ };
270
383
  }