@stardeck-customer-apps/compose 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -9,11 +9,13 @@ Every Module in `apps/web/src/modules/*` declares its mounts, endpoints,
9
9
  enhancements, contributions, i18n layers and data stores in `module.json`.
10
10
  Compose turns that declaration into the code Next.js actually builds:
11
11
 
12
- - `apps/web/src/modules.gen.ts` — the Module registry and `isModuleEnabled`
12
+ - `apps/web/src/modules.gen.ts` — the Module registry, `isModuleEnabled` and `isMountHeadless`
13
13
  - `apps/web/src/module-contributions.gen.ts`
14
14
  - `apps/web/src/module-i18n.gen.ts`
15
15
  - `apps/web/src/module-init.server.gen.ts`
16
16
  - `apps/web/src/module-datastores.gen.ts`
17
+ - `apps/web/src/module-server.gen.ts` — `MODULE_SERVER_REGISTRATIONS`, one entry
18
+ per composed Module that ships `server/registrations.ts`
17
19
  - one `page.tsx` / `route.ts` stub under `apps/web/src/app/**` per declared
18
20
  mount and endpoint
19
21
 
@@ -89,6 +91,31 @@ a silent fallback. It holds deployment facts: gitignore it (`apps/web/.gitignore
89
91
  locale; the locale also needs a `lib/i18n/substrate/<locale>.json`. New keys
90
92
  go in `lib/i18n/substrate/<locale>.json`, never in an override. Needs compose
91
93
  0.6.0 or later.
94
+ - **Headless mounts.** `NEXT_PUBLIC_STARDECK_HEADLESS_MOUNTS` (set by the
95
+ platform from the Modules tab) lists mount keys `<module>:<group>/<segment>`
96
+ from `module.json` `mounts`, comma-joined, e.g. `pos:(register)/register`.
97
+ The generated page for a listed mount returns not-found, and `admin.nav`
98
+ entries whose `href` is that mount's path (or below it) are dropped. The rest
99
+ of the Module keeps running: its other mounts, every endpoint, `server/`,
100
+ `client/`, initializers and other contributions. Build your own screen in
101
+ app code on the Module's `server/` entry and `client/` hooks, at a different
102
+ URL: the generated page still exists, so an app page at the same URL fails
103
+ compose as a route collision.
104
+ `isMountHeadless(key)` and `isHrefHeadless(href)` are exported from
105
+ `@/modules.gen`. Needs compose 0.7.0 or later.
106
+ - **Server registrations.** Per-Module server wiring that the app layer runs
107
+ (seeds, schedule handlers, and similar) goes in the Module's
108
+ `server/registrations.ts`, any Module kind. Compose imports each composed
109
+ Module's file as a namespace into `module-server.gen.ts`:
110
+ `MODULE_SERVER_REGISTRATIONS` is a
111
+ `readonly { module: string; registrations: Readonly<Record<string, unknown>> }[]`.
112
+ A Module left out by `--enabled` is not imported at all, so its server code
113
+ drops out of the build. App code reads that array from `@/module-server.gen`
114
+ and narrows the exports it expects (e.g. `registrations.seed`); it must never
115
+ import a Module's seed or schedules directly, or a disabled Module stays in
116
+ the bundle. Compose does not check the export names or shapes. The file
117
+ counts as `server/**/*.ts`, so the Module also needs `server/index.ts`.
118
+ Needs compose 0.8.0 or later.
92
119
  - `@stardeck-customer-apps/compose/runtime` exports `isModuleEnabledByList`,
93
120
  the single enablement rule, and imports nothing — it is safe in client
94
121
  components.
@@ -99,5 +126,5 @@ After each run compose rewrites `apps/web/src/app/.gitignore` with one entry per
99
126
  stub it emits — stubs are scattered one-per-route and cannot be globbed. Commit
100
127
  that file: it is deterministic output, and compose rewrites it only when the set
101
128
  of stubs changes, so an unchanged compose leaves `git status` clean. It does not
102
- list the five `src/*.gen.ts`; a static `src/*.gen.ts` line in `apps/web/.gitignore`
129
+ list the six `src/*.gen.ts`; a static `src/*.gen.ts` line in `apps/web/.gitignore`
103
130
  covers those. Compose refuses to touch the file if line 1 is not its own header.
package/dist/cli.js CHANGED
@@ -3891,10 +3891,12 @@ function analyzeClientSafeEntryImports(sourceText, fileName) {
3891
3891
  var APP_WEB_PREFIX = "apps/web/";
3892
3892
  var MODULE_STUB_MARKER = "@stardeck-module-rail-generated";
3893
3893
  var ENABLED_MODULES_ENV = "NEXT_PUBLIC_STARDECK_ENABLED_MODULES";
3894
+ var HEADLESS_MOUNTS_ENV = "NEXT_PUBLIC_STARDECK_HEADLESS_MOUNTS";
3894
3895
  var MODULE_CONTRIBUTIONS_GEN_PATH = `${APP_WEB_PREFIX}src/module-contributions.gen.ts`;
3895
3896
  var MODULE_DATASTORES_GEN_PATH = `${APP_WEB_PREFIX}src/module-datastores.gen.ts`;
3896
3897
  var MODULE_I18N_GEN_PATH = `${APP_WEB_PREFIX}src/module-i18n.gen.ts`;
3897
3898
  var MODULE_INIT_SERVER_GEN_PATH = `${APP_WEB_PREFIX}src/module-init.server.gen.ts`;
3899
+ var MODULE_SERVER_GEN_PATH = `${APP_WEB_PREFIX}src/module-server.gen.ts`;
3898
3900
  var MODULES_GEN_PATH = `${APP_WEB_PREFIX}src/modules.gen.ts`;
3899
3901
  var APP_EXTENSIONS_PATH = `${APP_WEB_PREFIX}src/lib/modules/app-extensions.ts`;
3900
3902
 
@@ -4127,6 +4129,11 @@ function isModuleEnabledByList(raw, name, kindOf) {
4127
4129
  return raw.split(",").includes(name);
4128
4130
  }
4129
4131
 
4132
+ // ../../packages/lib/src/shared/module-mount-key.ts
4133
+ function mountKey(moduleName, mount) {
4134
+ return `${moduleName}:${mount.group}/${mount.segment}`;
4135
+ }
4136
+
4130
4137
  // ../../packages/lib/src/server/module-rail/composition-artifacts.ts
4131
4138
  var import_typescript4 = __toESM(require("typescript"), 1);
4132
4139
 
@@ -4135,7 +4142,8 @@ var COMPOSITION_RUNTIME_GEN_PATHS = [
4135
4142
  MODULE_CONTRIBUTIONS_GEN_PATH,
4136
4143
  MODULE_I18N_GEN_PATH,
4137
4144
  MODULE_INIT_SERVER_GEN_PATH,
4138
- MODULE_DATASTORES_GEN_PATH
4145
+ MODULE_DATASTORES_GEN_PATH,
4146
+ MODULE_SERVER_GEN_PATH
4139
4147
  ];
4140
4148
  function compareNames(a, b) {
4141
4149
  return a < b ? -1 : a > b ? 1 : 0;
@@ -4156,6 +4164,9 @@ function formatI18nMarker() {
4156
4164
  function formatInitMarker() {
4157
4165
  return `// ${MODULE_STUB_MARKER} scope=init`;
4158
4166
  }
4167
+ function formatServerMarker() {
4168
+ return `// ${MODULE_STUB_MARKER} scope=server`;
4169
+ }
4159
4170
  function formatDatastoresMarker() {
4160
4171
  return `// ${MODULE_STUB_MARKER} scope=datastores`;
4161
4172
  }
@@ -4455,7 +4466,7 @@ function emitModuleContributionsGenTs(input) {
4455
4466
  " type SurfaceSlotCatalog,",
4456
4467
  '} from "@/lib/modules/contributions";',
4457
4468
  'import { substrateSlotCatalogs } from "@/lib/modules/substrate-slots";',
4458
- 'import { generatedModuleRegistry, isModuleEnabled } from "@/modules.gen";',
4469
+ 'import { generatedModuleRegistry, isHrefHeadless, isModuleEnabled } from "@/modules.gen";',
4459
4470
  ...[...moduleImportLines].sort((a, b) => {
4460
4471
  const pathA = a.match(/from ("[^"]+")/)?.[1] ?? a;
4461
4472
  const pathB = b.match(/from ("[^"]+")/)?.[1] ?? b;
@@ -4505,11 +4516,19 @@ function emitModuleContributionsGenTs(input) {
4505
4516
  "export function resolveModuleContributions(",
4506
4517
  " registry: typeof generatedModuleRegistry = generatedModuleRegistry",
4507
4518
  "): ResolvedModuleContributions {",
4508
- " return resolveContributions({",
4519
+ " const resolved = resolveContributions({",
4509
4520
  " registry,",
4510
4521
  " catalogs: moduleSlotCatalogs,",
4511
4522
  " sources: moduleContributionSources,",
4512
4523
  " });",
4524
+ " // A nav entry into a headless mount would link to a page that is switched off.",
4525
+ ' const nav = resolved["admin.nav"];',
4526
+ " if (nav) {",
4527
+ ' resolved["admin.nav"] = nav.filter(',
4528
+ " (entry) => !isHrefHeadless((entry as { href?: unknown }).href)",
4529
+ " );",
4530
+ " }",
4531
+ " return resolved;",
4513
4532
  "}",
4514
4533
  "",
4515
4534
  "export const resolvedModuleContributions: ResolvedModuleContributions =",
@@ -4791,6 +4810,42 @@ function emitModuleInitServerGenTs(input) {
4791
4810
  ""
4792
4811
  ].join("\n");
4793
4812
  }
4813
+ function emitModuleServerGenTs(input) {
4814
+ const installed = new Set(input.registry.modules.map((m) => m.name));
4815
+ const moduleNames = input.facts.filter((f) => f.hasServerRegistrations && installed.has(f.moduleName)).map((f) => f.moduleName).sort(compareNames);
4816
+ for (const name of moduleNames) {
4817
+ if (!isValidModuleName(name)) {
4818
+ throw new Error(`[ModuleRail] composition artifacts: invalid module name "${name}"`);
4819
+ }
4820
+ }
4821
+ const importLines = moduleNames.map(
4822
+ (name, index) => [
4823
+ `@/modules/${name}/server/registrations`,
4824
+ `import * as serverRegistrations${index} from ${JSON.stringify(`@/modules/${name}/server/registrations`)};`
4825
+ ]
4826
+ ).sort((a, b) => compareImportSpecifiers(a[0], b[0])).map(([, line]) => line);
4827
+ const entryLines = moduleNames.map(
4828
+ (name, index) => ` { module: ${JSON.stringify(name)}, registrations: serverRegistrations${index} },`
4829
+ );
4830
+ return [
4831
+ formatServerMarker(),
4832
+ "/** DO NOT EDIT \u2014 owned by the module install rail. */",
4833
+ "",
4834
+ ...importLines,
4835
+ ...importLines.length > 0 ? [""] : [],
4836
+ "export type ModuleServerRegistration = {",
4837
+ " module: string;",
4838
+ " registrations: Readonly<Record<string, unknown>>;",
4839
+ "};",
4840
+ "",
4841
+ ...entryLines.length === 0 ? ["export const MODULE_SERVER_REGISTRATIONS: readonly ModuleServerRegistration[] = [];"] : [
4842
+ "export const MODULE_SERVER_REGISTRATIONS: readonly ModuleServerRegistration[] = [",
4843
+ ...entryLines,
4844
+ "];"
4845
+ ],
4846
+ ""
4847
+ ].join("\n");
4848
+ }
4794
4849
 
4795
4850
  // ../../packages/lib/src/server/module-rail/composition-artifacts.ts
4796
4851
  var COMPOSITION_ARTIFACTS_GENERATED_BY = "module-rail";
@@ -4853,6 +4908,14 @@ function formatRouteMarker(owner, nextRelativePath) {
4853
4908
  function formatRegistryMarker() {
4854
4909
  return `// ${MODULE_STUB_MARKER} scope=registry`;
4855
4910
  }
4911
+ var OWNERLESS_MARKER_KINDS = [
4912
+ "registry",
4913
+ "contributions",
4914
+ "datastores",
4915
+ "i18n",
4916
+ "init",
4917
+ "server"
4918
+ ];
4856
4919
  function parseCompositionArtifactMarker(source) {
4857
4920
  const firstLine = source.split(/\r?\n/, 1)[0]?.trim() ?? "";
4858
4921
  const match = firstLine.match(
@@ -4863,26 +4926,8 @@ function parseCompositionArtifactMarker(source) {
4863
4926
  if (!match) return null;
4864
4927
  const owner = match[1];
4865
4928
  const scope = match[2];
4866
- if (scope === "registry") {
4867
- if (owner) return null;
4868
- return { kind: "registry" };
4869
- }
4870
- if (scope === "contributions") {
4871
- if (owner) return null;
4872
- return { kind: "contributions" };
4873
- }
4874
- if (scope === "datastores") {
4875
- if (owner) return null;
4876
- return { kind: "datastores" };
4877
- }
4878
- if (scope === "i18n") {
4879
- if (owner) return null;
4880
- return { kind: "i18n" };
4881
- }
4882
- if (scope === "init") {
4883
- if (owner) return null;
4884
- return { kind: "init" };
4885
- }
4929
+ const ownerless = OWNERLESS_MARKER_KINDS.find((kind) => kind === scope);
4930
+ if (ownerless) return owner ? null : { kind: ownerless };
4886
4931
  if (!owner || !isValidModuleName(owner)) return null;
4887
4932
  if (scope === "route") {
4888
4933
  const nextRelativePath = match[3]?.trim();
@@ -5008,6 +5053,7 @@ function expectedMarkerKindForPath(path3) {
5008
5053
  if (path3 === MODULE_DATASTORES_GEN_PATH) return "datastores";
5009
5054
  if (path3 === MODULE_I18N_GEN_PATH) return "i18n";
5010
5055
  if (path3 === MODULE_INIT_SERVER_GEN_PATH) return "init";
5056
+ if (path3 === MODULE_SERVER_GEN_PATH) return "server";
5011
5057
  if (path3.startsWith(API_ROUTE_DIR_PREFIX) && path3.endsWith("/route.ts")) return "endpoint";
5012
5058
  if (path3.startsWith(APP_DIR_PREFIX) && !path3.startsWith(API_ROUTE_DIR_PREFIX) && PAGE_FILE_SUFFIXES.some((suffix) => path3.endsWith(suffix))) {
5013
5059
  return "route";
@@ -5095,6 +5141,25 @@ function emitModulesGenTs(registry) {
5095
5141
  ' if (entry && entry.kind !== "feature") return true;',
5096
5142
  ' return raw.split(",").includes(name);',
5097
5143
  "}",
5144
+ "",
5145
+ "/** Headless mount keys come from the platform at build time: that page is off, its Module stays on. */",
5146
+ "export function isMountHeadless(key: string): boolean {",
5147
+ ` const raw = process.env.${HEADLESS_MOUNTS_ENV};`,
5148
+ ' return raw ? raw.split(",").includes(key) : false;',
5149
+ "}",
5150
+ "",
5151
+ "/** True when `href` is a headless mount's URL or below it. */",
5152
+ "export function isHrefHeadless(href: unknown): boolean {",
5153
+ ' if (typeof href !== "string") return false;',
5154
+ " const target = href.split(/[?#]/)[0];",
5155
+ " return generatedModuleRegistry.modules.some((mod) =>",
5156
+ " mod.mounts.some((mount) => {",
5157
+ " if (!isMountHeadless(`${mod.name}:${mount.group}/${mount.segment}`)) return false;",
5158
+ " const path = `/${mount.segment}`;",
5159
+ " return target === path || target.startsWith(`${path}/`);",
5160
+ " })",
5161
+ " );",
5162
+ "}",
5098
5163
  ""
5099
5164
  ].join("\n");
5100
5165
  }
@@ -5102,6 +5167,22 @@ var STUB_PRINT_WIDTH = 100;
5102
5167
  function wrapAtPrintWidth(flat, broken) {
5103
5168
  return flat.length > STUB_PRINT_WIDTH ? broken : [flat];
5104
5169
  }
5170
+ function emitGuardLines(guards, body) {
5171
+ const operands = guards.map((guard) => `${guard.call}(${emitTsString(guard.arg)})`);
5172
+ const condition = ` if (${operands.join(" || ")})`;
5173
+ const brokenOperands = guards.flatMap((guard, i) => {
5174
+ const suffix = i < guards.length - 1 ? " ||" : "";
5175
+ return wrapAtPrintWidth(` ${operands[i]}${suffix}`, [
5176
+ ` ${guard.call}(`,
5177
+ ` ${emitTsString(guard.arg)}`,
5178
+ ` )${suffix}`
5179
+ ]);
5180
+ });
5181
+ return wrapAtPrintWidth(`${condition} ${body}`, [
5182
+ ...wrapAtPrintWidth(condition, [" if (", ...brokenOperands, " )"]),
5183
+ ` ${body}`
5184
+ ]);
5185
+ }
5105
5186
  function buildEndpointStubContent(input) {
5106
5187
  const sorted = [...input.methods].sort(
5107
5188
  (a, b) => METHOD_ORDER[a.method] - METHOD_ORDER[b.method] || a.pairKey.localeCompare(b.pairKey)
@@ -5109,24 +5190,15 @@ function buildEndpointStubContent(input) {
5109
5190
  const exports2 = sorted.map((m) => {
5110
5191
  const pair = emitTsString(m.pairKey);
5111
5192
  const guards = [
5112
- `!isModuleEnabled(${emitTsString(input.owner)})`,
5113
- ...input.scope.kind === "enhancement" ? [`!isModuleEnabled(${emitTsString(input.scope.peer)})`] : []
5193
+ { call: "!isModuleEnabled", arg: input.owner },
5194
+ ...input.scope.kind === "enhancement" ? [{ call: "!isModuleEnabled", arg: input.scope.peer }] : []
5114
5195
  ];
5115
- const condition = ` if (${guards.join(" || ")})`;
5116
- const notFoundBody = "return new Response(null, { status: 404 });";
5117
5196
  const message = emitTsString(`Missing endpoint handler for ${m.pairKey}`);
5118
5197
  const importPath = emitTsString(input.importModule);
5119
5198
  return [
5120
5199
  `export async function ${m.method}(request: Request, context?: unknown): Promise<Response> {`,
5121
5200
  " await initializeActiveEnhancements();",
5122
- ...wrapAtPrintWidth(`${condition} ${notFoundBody}`, [
5123
- ...wrapAtPrintWidth(condition, [
5124
- " if (",
5125
- ...guards.map((guard, i) => ` ${guard}${i < guards.length - 1 ? " ||" : ""}`),
5126
- " )"
5127
- ]),
5128
- ` ${notFoundBody}`
5129
- ]),
5201
+ ...emitGuardLines(guards, "return new Response(null, { status: 404 });"),
5130
5202
  ...wrapAtPrintWidth(` const { endpointHandlers } = await import(${importPath});`, [
5131
5203
  " const { endpointHandlers } = await import(",
5132
5204
  ` ${importPath}`,
@@ -5156,12 +5228,16 @@ function buildEndpointStubContent(input) {
5156
5228
  ].join("\n");
5157
5229
  }
5158
5230
  function buildRouteStubContent(input) {
5231
+ const guards = [
5232
+ { call: "!isModuleEnabled", arg: input.owner },
5233
+ { call: "isMountHeadless", arg: input.mountKey }
5234
+ ];
5159
5235
  const lines = [
5160
5236
  formatRouteMarker(input.owner, input.nextRelativePath),
5161
5237
  'import { notFound } from "next/navigation";',
5162
5238
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5163
5239
  "",
5164
- 'import { isModuleEnabled } from "@/modules.gen";',
5240
+ 'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5165
5241
  `import Page from ${emitTsString(input.importModule)};`
5166
5242
  ];
5167
5243
  if (input.hasGenerateMetadata) {
@@ -5169,7 +5245,7 @@ function buildRouteStubContent(input) {
5169
5245
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5170
5246
  "",
5171
5247
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5172
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) return {};`,
5248
+ ...emitGuardLines(guards, "return {};"),
5173
5249
  " return sourceGenerateMetadata(...args);",
5174
5250
  "}"
5175
5251
  );
@@ -5180,7 +5256,7 @@ function buildRouteStubContent(input) {
5180
5256
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5181
5257
  "",
5182
5258
  "export default function ModulePage(props: PageProps) {",
5183
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) notFound();`,
5259
+ ...emitGuardLines(guards, "notFound();"),
5184
5260
  " return <Page {...props} />;",
5185
5261
  "}",
5186
5262
  ""
@@ -5382,7 +5458,6 @@ function planRouteStubs(input) {
5382
5458
  }
5383
5459
  for (const entry of entries) {
5384
5460
  const nextRelativePath = entry.nextRelativePath;
5385
- analyzeRouteEntryPath(nextRelativePath);
5386
5461
  const destination = normalizeAppRouterDestinationPattern(nextRelativePath);
5387
5462
  const path3 = routeEntryToAppPageFile(nextRelativePath);
5388
5463
  const existing = routeDestinationOwners.get(destination);
@@ -5397,6 +5472,8 @@ function planRouteStubs(input) {
5397
5472
  path: path3
5398
5473
  });
5399
5474
  const importModule = routeEntryImport(regMod.name, nextRelativePath);
5475
+ const analyzed = analyzeRouteEntryPath(nextRelativePath);
5476
+ const mount = mounts.find((claim) => routeEntryCoveredByMountClaim(analyzed, claim));
5400
5477
  routeStubs.push({
5401
5478
  path: path3,
5402
5479
  owner: regMod.name,
@@ -5406,6 +5483,7 @@ function planRouteStubs(input) {
5406
5483
  owner: regMod.name,
5407
5484
  nextRelativePath,
5408
5485
  importModule,
5486
+ mountKey: mountKey(regMod.name, mount),
5409
5487
  hasGenerateMetadata: entry.hasGenerateMetadata
5410
5488
  })
5411
5489
  });
@@ -5575,6 +5653,7 @@ function planCompositionArtifacts(input) {
5575
5653
  let datastoresContent;
5576
5654
  let i18nContent;
5577
5655
  let initContent;
5656
+ let serverContent;
5578
5657
  try {
5579
5658
  contributionsContent = emitModuleContributionsGenTs({
5580
5659
  registry: input.registry,
@@ -5599,6 +5678,10 @@ function planCompositionArtifacts(input) {
5599
5678
  registry: input.registry,
5600
5679
  facts: compositionEntryFacts
5601
5680
  });
5681
+ serverContent = emitModuleServerGenTs({
5682
+ registry: input.registry,
5683
+ facts: compositionEntryFacts
5684
+ });
5602
5685
  } catch (error) {
5603
5686
  return {
5604
5687
  ok: false,
@@ -5611,6 +5694,7 @@ function planCompositionArtifacts(input) {
5611
5694
  desired.set(MODULE_DATASTORES_GEN_PATH, datastoresContent);
5612
5695
  desired.set(MODULE_I18N_GEN_PATH, i18nContent);
5613
5696
  desired.set(MODULE_INIT_SERVER_GEN_PATH, initContent);
5697
+ desired.set(MODULE_SERVER_GEN_PATH, serverContent);
5614
5698
  for (const stub of stubs.endpointStubs) desired.set(stub.path, stub.content);
5615
5699
  for (const stub of stubs.routeStubs) desired.set(stub.path, stub.content);
5616
5700
  const endpointStubByPath = new Map(stubs.endpointStubs.map((stub) => [stub.path, stub]));
@@ -6881,6 +6965,7 @@ function readFailurePath(error) {
6881
6965
  return typeof error === "object" && error !== null && "path" in error ? String(error.path) : "unknown";
6882
6966
  }
6883
6967
  var CONVENTIONAL_ENTRY_KIND_PROBE_TOKEN = "conventional-entry-kind";
6968
+ var SERVER_REGISTRATIONS_FILE = "server/registrations.ts";
6884
6969
  var COMPOSITION_ARTIFACT_KIND_PROBE_TOKEN = "composition-artifact-kind";
6885
6970
  async function classifyCompositionArtifactPathKind(context, path3) {
6886
6971
  return classifySandboxPathPresenceKind(context, path3, COMPOSITION_ARTIFACT_KIND_PROBE_TOKEN);
@@ -6918,6 +7003,10 @@ async function readConventionalEntryIfRealFile(path3, kind, presences, files) {
6918
7003
  }
6919
7004
  return source;
6920
7005
  }
7006
+ function hasRealServerRegistrations(modulePath, presences) {
7007
+ const path3 = `${modulePath}/${SERVER_REGISTRATIONS_FILE}`;
7008
+ return assertRealCompositionArtifactFilePresence(path3, presences.get(path3) ?? "unreadable") === "file";
7009
+ }
6921
7010
  async function readJsonObjectFile(path3, files) {
6922
7011
  const raw = files.get(path3);
6923
7012
  let parsed;
@@ -6941,6 +7030,9 @@ async function collectCompositionEntryFacts(context, registry) {
6941
7030
  const overridesDir = `${APP_PACKAGE_DIR}/src/lib/i18n/overrides`;
6942
7031
  const localeDirs = [substrateDir, overridesDir];
6943
7032
  const conventionalPaths = [];
7033
+ const serverRegistrationPaths = registry.modules.map(
7034
+ (mod) => `${MODULES_SANDBOX_DIR}/${mod.name}/${SERVER_REGISTRATIONS_FILE}`
7035
+ );
6944
7036
  for (const mod of registry.modules) {
6945
7037
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
6946
7038
  localeDirs.push(`${modulePath}/i18n`);
@@ -6969,7 +7061,7 @@ async function collectCompositionEntryFacts(context, registry) {
6969
7061
  const [conventionalPresences, localePresences] = await Promise.all([
6970
7062
  classifySandboxPathPresenceKindMany(
6971
7063
  context,
6972
- conventionalPaths,
7064
+ [...conventionalPaths, ...serverRegistrationPaths],
6973
7065
  CONVENTIONAL_ENTRY_KIND_PROBE_TOKEN
6974
7066
  ),
6975
7067
  classifySandboxPathPresenceKindMany(context, localePaths, LOCALE_JSON_KIND_PROBE_TOKEN)
@@ -7031,6 +7123,7 @@ async function collectCompositionEntryFacts(context, registry) {
7031
7123
  );
7032
7124
  }
7033
7125
  }
7126
+ const hasServerRegistrations = hasRealServerRegistrations(modulePath, conventionalPresences);
7034
7127
  const rootLocales = localesByDir.get(`${modulePath}/i18n`);
7035
7128
  if (rootLocales.length > 0 && !rootLocales.includes("en")) {
7036
7129
  throw new Error(
@@ -7070,6 +7163,7 @@ async function collectCompositionEntryFacts(context, registry) {
7070
7163
  moduleName: mod.name,
7071
7164
  hasRootContributions,
7072
7165
  hasRootSlots,
7166
+ hasServerRegistrations,
7073
7167
  rootLocales,
7074
7168
  enhancements
7075
7169
  });
@@ -7273,6 +7367,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7273
7367
  MODULE_DATASTORES_GEN_PATH,
7274
7368
  MODULE_I18N_GEN_PATH,
7275
7369
  MODULE_INIT_SERVER_GEN_PATH,
7370
+ MODULE_SERVER_GEN_PATH,
7276
7371
  ...existingGeneratedPaths,
7277
7372
  ...Object.keys(existingAppPages),
7278
7373
  ...Object.keys(existingAppRoutes)
package/dist/index.d.mts CHANGED
@@ -89,7 +89,7 @@ declare function assertSupportedTypescript(versionMajorMinor: string): void;
89
89
  */
90
90
  declare function escapeGitignore(pattern: string): string;
91
91
  /**
92
- * Regenerate the Module-rail artifacts (the five `src/*.gen.ts` files and every
92
+ * Regenerate the Module-rail artifacts (the six `src/*.gen.ts` files and every
93
93
  * route/endpoint stub under `src/app`) for the app rooted at `options.cwd`.
94
94
  */
95
95
  declare function compose(options: ComposeOptions): Promise<ComposeResult>;
package/dist/index.d.ts CHANGED
@@ -89,7 +89,7 @@ declare function assertSupportedTypescript(versionMajorMinor: string): void;
89
89
  */
90
90
  declare function escapeGitignore(pattern: string): string;
91
91
  /**
92
- * Regenerate the Module-rail artifacts (the five `src/*.gen.ts` files and every
92
+ * Regenerate the Module-rail artifacts (the six `src/*.gen.ts` files and every
93
93
  * route/endpoint stub under `src/app`) for the app rooted at `options.cwd`.
94
94
  */
95
95
  declare function compose(options: ComposeOptions): Promise<ComposeResult>;
package/dist/index.js CHANGED
@@ -4129,13 +4129,20 @@ function isModuleEnabledByList(raw, name, kindOf) {
4129
4129
  var APP_WEB_PREFIX = "apps/web/";
4130
4130
  var MODULE_STUB_MARKER = "@stardeck-module-rail-generated";
4131
4131
  var ENABLED_MODULES_ENV = "NEXT_PUBLIC_STARDECK_ENABLED_MODULES";
4132
+ var HEADLESS_MOUNTS_ENV = "NEXT_PUBLIC_STARDECK_HEADLESS_MOUNTS";
4132
4133
  var MODULE_CONTRIBUTIONS_GEN_PATH = `${APP_WEB_PREFIX}src/module-contributions.gen.ts`;
4133
4134
  var MODULE_DATASTORES_GEN_PATH = `${APP_WEB_PREFIX}src/module-datastores.gen.ts`;
4134
4135
  var MODULE_I18N_GEN_PATH = `${APP_WEB_PREFIX}src/module-i18n.gen.ts`;
4135
4136
  var MODULE_INIT_SERVER_GEN_PATH = `${APP_WEB_PREFIX}src/module-init.server.gen.ts`;
4137
+ var MODULE_SERVER_GEN_PATH = `${APP_WEB_PREFIX}src/module-server.gen.ts`;
4136
4138
  var MODULES_GEN_PATH = `${APP_WEB_PREFIX}src/modules.gen.ts`;
4137
4139
  var APP_EXTENSIONS_PATH = `${APP_WEB_PREFIX}src/lib/modules/app-extensions.ts`;
4138
4140
 
4141
+ // ../../packages/lib/src/shared/module-mount-key.ts
4142
+ function mountKey(moduleName, mount) {
4143
+ return `${moduleName}:${mount.group}/${mount.segment}`;
4144
+ }
4145
+
4139
4146
  // ../../packages/lib/src/server/module-rail/composition-artifacts.ts
4140
4147
  var import_typescript4 = __toESM(require("typescript"), 1);
4141
4148
 
@@ -4144,7 +4151,8 @@ var COMPOSITION_RUNTIME_GEN_PATHS = [
4144
4151
  MODULE_CONTRIBUTIONS_GEN_PATH,
4145
4152
  MODULE_I18N_GEN_PATH,
4146
4153
  MODULE_INIT_SERVER_GEN_PATH,
4147
- MODULE_DATASTORES_GEN_PATH
4154
+ MODULE_DATASTORES_GEN_PATH,
4155
+ MODULE_SERVER_GEN_PATH
4148
4156
  ];
4149
4157
  function compareNames(a, b) {
4150
4158
  return a < b ? -1 : a > b ? 1 : 0;
@@ -4165,6 +4173,9 @@ function formatI18nMarker() {
4165
4173
  function formatInitMarker() {
4166
4174
  return `// ${MODULE_STUB_MARKER} scope=init`;
4167
4175
  }
4176
+ function formatServerMarker() {
4177
+ return `// ${MODULE_STUB_MARKER} scope=server`;
4178
+ }
4168
4179
  function formatDatastoresMarker() {
4169
4180
  return `// ${MODULE_STUB_MARKER} scope=datastores`;
4170
4181
  }
@@ -4464,7 +4475,7 @@ function emitModuleContributionsGenTs(input) {
4464
4475
  " type SurfaceSlotCatalog,",
4465
4476
  '} from "@/lib/modules/contributions";',
4466
4477
  'import { substrateSlotCatalogs } from "@/lib/modules/substrate-slots";',
4467
- 'import { generatedModuleRegistry, isModuleEnabled } from "@/modules.gen";',
4478
+ 'import { generatedModuleRegistry, isHrefHeadless, isModuleEnabled } from "@/modules.gen";',
4468
4479
  ...[...moduleImportLines].sort((a, b) => {
4469
4480
  const pathA = a.match(/from ("[^"]+")/)?.[1] ?? a;
4470
4481
  const pathB = b.match(/from ("[^"]+")/)?.[1] ?? b;
@@ -4514,11 +4525,19 @@ function emitModuleContributionsGenTs(input) {
4514
4525
  "export function resolveModuleContributions(",
4515
4526
  " registry: typeof generatedModuleRegistry = generatedModuleRegistry",
4516
4527
  "): ResolvedModuleContributions {",
4517
- " return resolveContributions({",
4528
+ " const resolved = resolveContributions({",
4518
4529
  " registry,",
4519
4530
  " catalogs: moduleSlotCatalogs,",
4520
4531
  " sources: moduleContributionSources,",
4521
4532
  " });",
4533
+ " // A nav entry into a headless mount would link to a page that is switched off.",
4534
+ ' const nav = resolved["admin.nav"];',
4535
+ " if (nav) {",
4536
+ ' resolved["admin.nav"] = nav.filter(',
4537
+ " (entry) => !isHrefHeadless((entry as { href?: unknown }).href)",
4538
+ " );",
4539
+ " }",
4540
+ " return resolved;",
4522
4541
  "}",
4523
4542
  "",
4524
4543
  "export const resolvedModuleContributions: ResolvedModuleContributions =",
@@ -4800,6 +4819,42 @@ function emitModuleInitServerGenTs(input) {
4800
4819
  ""
4801
4820
  ].join("\n");
4802
4821
  }
4822
+ function emitModuleServerGenTs(input) {
4823
+ const installed = new Set(input.registry.modules.map((m) => m.name));
4824
+ const moduleNames = input.facts.filter((f) => f.hasServerRegistrations && installed.has(f.moduleName)).map((f) => f.moduleName).sort(compareNames);
4825
+ for (const name of moduleNames) {
4826
+ if (!isValidModuleName(name)) {
4827
+ throw new Error(`[ModuleRail] composition artifacts: invalid module name "${name}"`);
4828
+ }
4829
+ }
4830
+ const importLines = moduleNames.map(
4831
+ (name, index) => [
4832
+ `@/modules/${name}/server/registrations`,
4833
+ `import * as serverRegistrations${index} from ${JSON.stringify(`@/modules/${name}/server/registrations`)};`
4834
+ ]
4835
+ ).sort((a, b) => compareImportSpecifiers(a[0], b[0])).map(([, line]) => line);
4836
+ const entryLines = moduleNames.map(
4837
+ (name, index) => ` { module: ${JSON.stringify(name)}, registrations: serverRegistrations${index} },`
4838
+ );
4839
+ return [
4840
+ formatServerMarker(),
4841
+ "/** DO NOT EDIT \u2014 owned by the module install rail. */",
4842
+ "",
4843
+ ...importLines,
4844
+ ...importLines.length > 0 ? [""] : [],
4845
+ "export type ModuleServerRegistration = {",
4846
+ " module: string;",
4847
+ " registrations: Readonly<Record<string, unknown>>;",
4848
+ "};",
4849
+ "",
4850
+ ...entryLines.length === 0 ? ["export const MODULE_SERVER_REGISTRATIONS: readonly ModuleServerRegistration[] = [];"] : [
4851
+ "export const MODULE_SERVER_REGISTRATIONS: readonly ModuleServerRegistration[] = [",
4852
+ ...entryLines,
4853
+ "];"
4854
+ ],
4855
+ ""
4856
+ ].join("\n");
4857
+ }
4803
4858
 
4804
4859
  // ../../packages/lib/src/server/module-rail/composition-artifacts.ts
4805
4860
  var COMPOSITION_ARTIFACTS_GENERATED_BY = "module-rail";
@@ -4862,6 +4917,14 @@ function formatRouteMarker(owner, nextRelativePath) {
4862
4917
  function formatRegistryMarker() {
4863
4918
  return `// ${MODULE_STUB_MARKER} scope=registry`;
4864
4919
  }
4920
+ var OWNERLESS_MARKER_KINDS = [
4921
+ "registry",
4922
+ "contributions",
4923
+ "datastores",
4924
+ "i18n",
4925
+ "init",
4926
+ "server"
4927
+ ];
4865
4928
  function parseCompositionArtifactMarker(source) {
4866
4929
  const firstLine = source.split(/\r?\n/, 1)[0]?.trim() ?? "";
4867
4930
  const match = firstLine.match(
@@ -4872,26 +4935,8 @@ function parseCompositionArtifactMarker(source) {
4872
4935
  if (!match) return null;
4873
4936
  const owner = match[1];
4874
4937
  const scope = match[2];
4875
- if (scope === "registry") {
4876
- if (owner) return null;
4877
- return { kind: "registry" };
4878
- }
4879
- if (scope === "contributions") {
4880
- if (owner) return null;
4881
- return { kind: "contributions" };
4882
- }
4883
- if (scope === "datastores") {
4884
- if (owner) return null;
4885
- return { kind: "datastores" };
4886
- }
4887
- if (scope === "i18n") {
4888
- if (owner) return null;
4889
- return { kind: "i18n" };
4890
- }
4891
- if (scope === "init") {
4892
- if (owner) return null;
4893
- return { kind: "init" };
4894
- }
4938
+ const ownerless = OWNERLESS_MARKER_KINDS.find((kind) => kind === scope);
4939
+ if (ownerless) return owner ? null : { kind: ownerless };
4895
4940
  if (!owner || !isValidModuleName(owner)) return null;
4896
4941
  if (scope === "route") {
4897
4942
  const nextRelativePath = match[3]?.trim();
@@ -5017,6 +5062,7 @@ function expectedMarkerKindForPath(path3) {
5017
5062
  if (path3 === MODULE_DATASTORES_GEN_PATH) return "datastores";
5018
5063
  if (path3 === MODULE_I18N_GEN_PATH) return "i18n";
5019
5064
  if (path3 === MODULE_INIT_SERVER_GEN_PATH) return "init";
5065
+ if (path3 === MODULE_SERVER_GEN_PATH) return "server";
5020
5066
  if (path3.startsWith(API_ROUTE_DIR_PREFIX) && path3.endsWith("/route.ts")) return "endpoint";
5021
5067
  if (path3.startsWith(APP_DIR_PREFIX) && !path3.startsWith(API_ROUTE_DIR_PREFIX) && PAGE_FILE_SUFFIXES.some((suffix) => path3.endsWith(suffix))) {
5022
5068
  return "route";
@@ -5104,6 +5150,25 @@ function emitModulesGenTs(registry) {
5104
5150
  ' if (entry && entry.kind !== "feature") return true;',
5105
5151
  ' return raw.split(",").includes(name);',
5106
5152
  "}",
5153
+ "",
5154
+ "/** Headless mount keys come from the platform at build time: that page is off, its Module stays on. */",
5155
+ "export function isMountHeadless(key: string): boolean {",
5156
+ ` const raw = process.env.${HEADLESS_MOUNTS_ENV};`,
5157
+ ' return raw ? raw.split(",").includes(key) : false;',
5158
+ "}",
5159
+ "",
5160
+ "/** True when `href` is a headless mount's URL or below it. */",
5161
+ "export function isHrefHeadless(href: unknown): boolean {",
5162
+ ' if (typeof href !== "string") return false;',
5163
+ " const target = href.split(/[?#]/)[0];",
5164
+ " return generatedModuleRegistry.modules.some((mod) =>",
5165
+ " mod.mounts.some((mount) => {",
5166
+ " if (!isMountHeadless(`${mod.name}:${mount.group}/${mount.segment}`)) return false;",
5167
+ " const path = `/${mount.segment}`;",
5168
+ " return target === path || target.startsWith(`${path}/`);",
5169
+ " })",
5170
+ " );",
5171
+ "}",
5107
5172
  ""
5108
5173
  ].join("\n");
5109
5174
  }
@@ -5111,6 +5176,22 @@ var STUB_PRINT_WIDTH = 100;
5111
5176
  function wrapAtPrintWidth(flat, broken) {
5112
5177
  return flat.length > STUB_PRINT_WIDTH ? broken : [flat];
5113
5178
  }
5179
+ function emitGuardLines(guards, body) {
5180
+ const operands = guards.map((guard) => `${guard.call}(${emitTsString(guard.arg)})`);
5181
+ const condition = ` if (${operands.join(" || ")})`;
5182
+ const brokenOperands = guards.flatMap((guard, i) => {
5183
+ const suffix = i < guards.length - 1 ? " ||" : "";
5184
+ return wrapAtPrintWidth(` ${operands[i]}${suffix}`, [
5185
+ ` ${guard.call}(`,
5186
+ ` ${emitTsString(guard.arg)}`,
5187
+ ` )${suffix}`
5188
+ ]);
5189
+ });
5190
+ return wrapAtPrintWidth(`${condition} ${body}`, [
5191
+ ...wrapAtPrintWidth(condition, [" if (", ...brokenOperands, " )"]),
5192
+ ` ${body}`
5193
+ ]);
5194
+ }
5114
5195
  function buildEndpointStubContent(input) {
5115
5196
  const sorted = [...input.methods].sort(
5116
5197
  (a, b) => METHOD_ORDER[a.method] - METHOD_ORDER[b.method] || a.pairKey.localeCompare(b.pairKey)
@@ -5118,24 +5199,15 @@ function buildEndpointStubContent(input) {
5118
5199
  const exports2 = sorted.map((m) => {
5119
5200
  const pair = emitTsString(m.pairKey);
5120
5201
  const guards = [
5121
- `!isModuleEnabled(${emitTsString(input.owner)})`,
5122
- ...input.scope.kind === "enhancement" ? [`!isModuleEnabled(${emitTsString(input.scope.peer)})`] : []
5202
+ { call: "!isModuleEnabled", arg: input.owner },
5203
+ ...input.scope.kind === "enhancement" ? [{ call: "!isModuleEnabled", arg: input.scope.peer }] : []
5123
5204
  ];
5124
- const condition = ` if (${guards.join(" || ")})`;
5125
- const notFoundBody = "return new Response(null, { status: 404 });";
5126
5205
  const message = emitTsString(`Missing endpoint handler for ${m.pairKey}`);
5127
5206
  const importPath = emitTsString(input.importModule);
5128
5207
  return [
5129
5208
  `export async function ${m.method}(request: Request, context?: unknown): Promise<Response> {`,
5130
5209
  " await initializeActiveEnhancements();",
5131
- ...wrapAtPrintWidth(`${condition} ${notFoundBody}`, [
5132
- ...wrapAtPrintWidth(condition, [
5133
- " if (",
5134
- ...guards.map((guard, i) => ` ${guard}${i < guards.length - 1 ? " ||" : ""}`),
5135
- " )"
5136
- ]),
5137
- ` ${notFoundBody}`
5138
- ]),
5210
+ ...emitGuardLines(guards, "return new Response(null, { status: 404 });"),
5139
5211
  ...wrapAtPrintWidth(` const { endpointHandlers } = await import(${importPath});`, [
5140
5212
  " const { endpointHandlers } = await import(",
5141
5213
  ` ${importPath}`,
@@ -5165,12 +5237,16 @@ function buildEndpointStubContent(input) {
5165
5237
  ].join("\n");
5166
5238
  }
5167
5239
  function buildRouteStubContent(input) {
5240
+ const guards = [
5241
+ { call: "!isModuleEnabled", arg: input.owner },
5242
+ { call: "isMountHeadless", arg: input.mountKey }
5243
+ ];
5168
5244
  const lines = [
5169
5245
  formatRouteMarker(input.owner, input.nextRelativePath),
5170
5246
  'import { notFound } from "next/navigation";',
5171
5247
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5172
5248
  "",
5173
- 'import { isModuleEnabled } from "@/modules.gen";',
5249
+ 'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5174
5250
  `import Page from ${emitTsString(input.importModule)};`
5175
5251
  ];
5176
5252
  if (input.hasGenerateMetadata) {
@@ -5178,7 +5254,7 @@ function buildRouteStubContent(input) {
5178
5254
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5179
5255
  "",
5180
5256
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5181
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) return {};`,
5257
+ ...emitGuardLines(guards, "return {};"),
5182
5258
  " return sourceGenerateMetadata(...args);",
5183
5259
  "}"
5184
5260
  );
@@ -5189,7 +5265,7 @@ function buildRouteStubContent(input) {
5189
5265
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5190
5266
  "",
5191
5267
  "export default function ModulePage(props: PageProps) {",
5192
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) notFound();`,
5268
+ ...emitGuardLines(guards, "notFound();"),
5193
5269
  " return <Page {...props} />;",
5194
5270
  "}",
5195
5271
  ""
@@ -5391,7 +5467,6 @@ function planRouteStubs(input) {
5391
5467
  }
5392
5468
  for (const entry of entries) {
5393
5469
  const nextRelativePath = entry.nextRelativePath;
5394
- analyzeRouteEntryPath(nextRelativePath);
5395
5470
  const destination = normalizeAppRouterDestinationPattern(nextRelativePath);
5396
5471
  const path3 = routeEntryToAppPageFile(nextRelativePath);
5397
5472
  const existing = routeDestinationOwners.get(destination);
@@ -5406,6 +5481,8 @@ function planRouteStubs(input) {
5406
5481
  path: path3
5407
5482
  });
5408
5483
  const importModule = routeEntryImport(regMod.name, nextRelativePath);
5484
+ const analyzed = analyzeRouteEntryPath(nextRelativePath);
5485
+ const mount = mounts.find((claim) => routeEntryCoveredByMountClaim(analyzed, claim));
5409
5486
  routeStubs.push({
5410
5487
  path: path3,
5411
5488
  owner: regMod.name,
@@ -5415,6 +5492,7 @@ function planRouteStubs(input) {
5415
5492
  owner: regMod.name,
5416
5493
  nextRelativePath,
5417
5494
  importModule,
5495
+ mountKey: mountKey(regMod.name, mount),
5418
5496
  hasGenerateMetadata: entry.hasGenerateMetadata
5419
5497
  })
5420
5498
  });
@@ -5584,6 +5662,7 @@ function planCompositionArtifacts(input) {
5584
5662
  let datastoresContent;
5585
5663
  let i18nContent;
5586
5664
  let initContent;
5665
+ let serverContent;
5587
5666
  try {
5588
5667
  contributionsContent = emitModuleContributionsGenTs({
5589
5668
  registry: input.registry,
@@ -5608,6 +5687,10 @@ function planCompositionArtifacts(input) {
5608
5687
  registry: input.registry,
5609
5688
  facts: compositionEntryFacts
5610
5689
  });
5690
+ serverContent = emitModuleServerGenTs({
5691
+ registry: input.registry,
5692
+ facts: compositionEntryFacts
5693
+ });
5611
5694
  } catch (error) {
5612
5695
  return {
5613
5696
  ok: false,
@@ -5620,6 +5703,7 @@ function planCompositionArtifacts(input) {
5620
5703
  desired.set(MODULE_DATASTORES_GEN_PATH, datastoresContent);
5621
5704
  desired.set(MODULE_I18N_GEN_PATH, i18nContent);
5622
5705
  desired.set(MODULE_INIT_SERVER_GEN_PATH, initContent);
5706
+ desired.set(MODULE_SERVER_GEN_PATH, serverContent);
5623
5707
  for (const stub of stubs.endpointStubs) desired.set(stub.path, stub.content);
5624
5708
  for (const stub of stubs.routeStubs) desired.set(stub.path, stub.content);
5625
5709
  const endpointStubByPath = new Map(stubs.endpointStubs.map((stub) => [stub.path, stub]));
@@ -6890,6 +6974,7 @@ function readFailurePath(error) {
6890
6974
  return typeof error === "object" && error !== null && "path" in error ? String(error.path) : "unknown";
6891
6975
  }
6892
6976
  var CONVENTIONAL_ENTRY_KIND_PROBE_TOKEN = "conventional-entry-kind";
6977
+ var SERVER_REGISTRATIONS_FILE = "server/registrations.ts";
6893
6978
  var COMPOSITION_ARTIFACT_KIND_PROBE_TOKEN = "composition-artifact-kind";
6894
6979
  async function classifyCompositionArtifactPathKind(context, path3) {
6895
6980
  return classifySandboxPathPresenceKind(context, path3, COMPOSITION_ARTIFACT_KIND_PROBE_TOKEN);
@@ -6927,6 +7012,10 @@ async function readConventionalEntryIfRealFile(path3, kind, presences, files) {
6927
7012
  }
6928
7013
  return source;
6929
7014
  }
7015
+ function hasRealServerRegistrations(modulePath, presences) {
7016
+ const path3 = `${modulePath}/${SERVER_REGISTRATIONS_FILE}`;
7017
+ return assertRealCompositionArtifactFilePresence(path3, presences.get(path3) ?? "unreadable") === "file";
7018
+ }
6930
7019
  async function readJsonObjectFile(path3, files) {
6931
7020
  const raw = files.get(path3);
6932
7021
  let parsed;
@@ -6950,6 +7039,9 @@ async function collectCompositionEntryFacts(context, registry) {
6950
7039
  const overridesDir = `${APP_PACKAGE_DIR}/src/lib/i18n/overrides`;
6951
7040
  const localeDirs = [substrateDir, overridesDir];
6952
7041
  const conventionalPaths = [];
7042
+ const serverRegistrationPaths = registry.modules.map(
7043
+ (mod) => `${MODULES_SANDBOX_DIR}/${mod.name}/${SERVER_REGISTRATIONS_FILE}`
7044
+ );
6953
7045
  for (const mod of registry.modules) {
6954
7046
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
6955
7047
  localeDirs.push(`${modulePath}/i18n`);
@@ -6978,7 +7070,7 @@ async function collectCompositionEntryFacts(context, registry) {
6978
7070
  const [conventionalPresences, localePresences] = await Promise.all([
6979
7071
  classifySandboxPathPresenceKindMany(
6980
7072
  context,
6981
- conventionalPaths,
7073
+ [...conventionalPaths, ...serverRegistrationPaths],
6982
7074
  CONVENTIONAL_ENTRY_KIND_PROBE_TOKEN
6983
7075
  ),
6984
7076
  classifySandboxPathPresenceKindMany(context, localePaths, LOCALE_JSON_KIND_PROBE_TOKEN)
@@ -7040,6 +7132,7 @@ async function collectCompositionEntryFacts(context, registry) {
7040
7132
  );
7041
7133
  }
7042
7134
  }
7135
+ const hasServerRegistrations = hasRealServerRegistrations(modulePath, conventionalPresences);
7043
7136
  const rootLocales = localesByDir.get(`${modulePath}/i18n`);
7044
7137
  if (rootLocales.length > 0 && !rootLocales.includes("en")) {
7045
7138
  throw new Error(
@@ -7079,6 +7172,7 @@ async function collectCompositionEntryFacts(context, registry) {
7079
7172
  moduleName: mod.name,
7080
7173
  hasRootContributions,
7081
7174
  hasRootSlots,
7175
+ hasServerRegistrations,
7082
7176
  rootLocales,
7083
7177
  enhancements
7084
7178
  });
@@ -7282,6 +7376,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7282
7376
  MODULE_DATASTORES_GEN_PATH,
7283
7377
  MODULE_I18N_GEN_PATH,
7284
7378
  MODULE_INIT_SERVER_GEN_PATH,
7379
+ MODULE_SERVER_GEN_PATH,
7285
7380
  ...existingGeneratedPaths,
7286
7381
  ...Object.keys(existingAppPages),
7287
7382
  ...Object.keys(existingAppRoutes)
package/dist/index.mjs CHANGED
@@ -4114,13 +4114,20 @@ function isModuleEnabledByList(raw, name, kindOf) {
4114
4114
  var APP_WEB_PREFIX = "apps/web/";
4115
4115
  var MODULE_STUB_MARKER = "@stardeck-module-rail-generated";
4116
4116
  var ENABLED_MODULES_ENV = "NEXT_PUBLIC_STARDECK_ENABLED_MODULES";
4117
+ var HEADLESS_MOUNTS_ENV = "NEXT_PUBLIC_STARDECK_HEADLESS_MOUNTS";
4117
4118
  var MODULE_CONTRIBUTIONS_GEN_PATH = `${APP_WEB_PREFIX}src/module-contributions.gen.ts`;
4118
4119
  var MODULE_DATASTORES_GEN_PATH = `${APP_WEB_PREFIX}src/module-datastores.gen.ts`;
4119
4120
  var MODULE_I18N_GEN_PATH = `${APP_WEB_PREFIX}src/module-i18n.gen.ts`;
4120
4121
  var MODULE_INIT_SERVER_GEN_PATH = `${APP_WEB_PREFIX}src/module-init.server.gen.ts`;
4122
+ var MODULE_SERVER_GEN_PATH = `${APP_WEB_PREFIX}src/module-server.gen.ts`;
4121
4123
  var MODULES_GEN_PATH = `${APP_WEB_PREFIX}src/modules.gen.ts`;
4122
4124
  var APP_EXTENSIONS_PATH = `${APP_WEB_PREFIX}src/lib/modules/app-extensions.ts`;
4123
4125
 
4126
+ // ../../packages/lib/src/shared/module-mount-key.ts
4127
+ function mountKey(moduleName, mount) {
4128
+ return `${moduleName}:${mount.group}/${mount.segment}`;
4129
+ }
4130
+
4124
4131
  // ../../packages/lib/src/server/module-rail/composition-artifacts.ts
4125
4132
  import ts4 from "typescript";
4126
4133
 
@@ -4129,7 +4136,8 @@ var COMPOSITION_RUNTIME_GEN_PATHS = [
4129
4136
  MODULE_CONTRIBUTIONS_GEN_PATH,
4130
4137
  MODULE_I18N_GEN_PATH,
4131
4138
  MODULE_INIT_SERVER_GEN_PATH,
4132
- MODULE_DATASTORES_GEN_PATH
4139
+ MODULE_DATASTORES_GEN_PATH,
4140
+ MODULE_SERVER_GEN_PATH
4133
4141
  ];
4134
4142
  function compareNames(a, b) {
4135
4143
  return a < b ? -1 : a > b ? 1 : 0;
@@ -4150,6 +4158,9 @@ function formatI18nMarker() {
4150
4158
  function formatInitMarker() {
4151
4159
  return `// ${MODULE_STUB_MARKER} scope=init`;
4152
4160
  }
4161
+ function formatServerMarker() {
4162
+ return `// ${MODULE_STUB_MARKER} scope=server`;
4163
+ }
4153
4164
  function formatDatastoresMarker() {
4154
4165
  return `// ${MODULE_STUB_MARKER} scope=datastores`;
4155
4166
  }
@@ -4449,7 +4460,7 @@ function emitModuleContributionsGenTs(input) {
4449
4460
  " type SurfaceSlotCatalog,",
4450
4461
  '} from "@/lib/modules/contributions";',
4451
4462
  'import { substrateSlotCatalogs } from "@/lib/modules/substrate-slots";',
4452
- 'import { generatedModuleRegistry, isModuleEnabled } from "@/modules.gen";',
4463
+ 'import { generatedModuleRegistry, isHrefHeadless, isModuleEnabled } from "@/modules.gen";',
4453
4464
  ...[...moduleImportLines].sort((a, b) => {
4454
4465
  const pathA = a.match(/from ("[^"]+")/)?.[1] ?? a;
4455
4466
  const pathB = b.match(/from ("[^"]+")/)?.[1] ?? b;
@@ -4499,11 +4510,19 @@ function emitModuleContributionsGenTs(input) {
4499
4510
  "export function resolveModuleContributions(",
4500
4511
  " registry: typeof generatedModuleRegistry = generatedModuleRegistry",
4501
4512
  "): ResolvedModuleContributions {",
4502
- " return resolveContributions({",
4513
+ " const resolved = resolveContributions({",
4503
4514
  " registry,",
4504
4515
  " catalogs: moduleSlotCatalogs,",
4505
4516
  " sources: moduleContributionSources,",
4506
4517
  " });",
4518
+ " // A nav entry into a headless mount would link to a page that is switched off.",
4519
+ ' const nav = resolved["admin.nav"];',
4520
+ " if (nav) {",
4521
+ ' resolved["admin.nav"] = nav.filter(',
4522
+ " (entry) => !isHrefHeadless((entry as { href?: unknown }).href)",
4523
+ " );",
4524
+ " }",
4525
+ " return resolved;",
4507
4526
  "}",
4508
4527
  "",
4509
4528
  "export const resolvedModuleContributions: ResolvedModuleContributions =",
@@ -4785,6 +4804,42 @@ function emitModuleInitServerGenTs(input) {
4785
4804
  ""
4786
4805
  ].join("\n");
4787
4806
  }
4807
+ function emitModuleServerGenTs(input) {
4808
+ const installed = new Set(input.registry.modules.map((m) => m.name));
4809
+ const moduleNames = input.facts.filter((f) => f.hasServerRegistrations && installed.has(f.moduleName)).map((f) => f.moduleName).sort(compareNames);
4810
+ for (const name of moduleNames) {
4811
+ if (!isValidModuleName(name)) {
4812
+ throw new Error(`[ModuleRail] composition artifacts: invalid module name "${name}"`);
4813
+ }
4814
+ }
4815
+ const importLines = moduleNames.map(
4816
+ (name, index) => [
4817
+ `@/modules/${name}/server/registrations`,
4818
+ `import * as serverRegistrations${index} from ${JSON.stringify(`@/modules/${name}/server/registrations`)};`
4819
+ ]
4820
+ ).sort((a, b) => compareImportSpecifiers(a[0], b[0])).map(([, line]) => line);
4821
+ const entryLines = moduleNames.map(
4822
+ (name, index) => ` { module: ${JSON.stringify(name)}, registrations: serverRegistrations${index} },`
4823
+ );
4824
+ return [
4825
+ formatServerMarker(),
4826
+ "/** DO NOT EDIT \u2014 owned by the module install rail. */",
4827
+ "",
4828
+ ...importLines,
4829
+ ...importLines.length > 0 ? [""] : [],
4830
+ "export type ModuleServerRegistration = {",
4831
+ " module: string;",
4832
+ " registrations: Readonly<Record<string, unknown>>;",
4833
+ "};",
4834
+ "",
4835
+ ...entryLines.length === 0 ? ["export const MODULE_SERVER_REGISTRATIONS: readonly ModuleServerRegistration[] = [];"] : [
4836
+ "export const MODULE_SERVER_REGISTRATIONS: readonly ModuleServerRegistration[] = [",
4837
+ ...entryLines,
4838
+ "];"
4839
+ ],
4840
+ ""
4841
+ ].join("\n");
4842
+ }
4788
4843
 
4789
4844
  // ../../packages/lib/src/server/module-rail/composition-artifacts.ts
4790
4845
  var COMPOSITION_ARTIFACTS_GENERATED_BY = "module-rail";
@@ -4847,6 +4902,14 @@ function formatRouteMarker(owner, nextRelativePath) {
4847
4902
  function formatRegistryMarker() {
4848
4903
  return `// ${MODULE_STUB_MARKER} scope=registry`;
4849
4904
  }
4905
+ var OWNERLESS_MARKER_KINDS = [
4906
+ "registry",
4907
+ "contributions",
4908
+ "datastores",
4909
+ "i18n",
4910
+ "init",
4911
+ "server"
4912
+ ];
4850
4913
  function parseCompositionArtifactMarker(source) {
4851
4914
  const firstLine = source.split(/\r?\n/, 1)[0]?.trim() ?? "";
4852
4915
  const match = firstLine.match(
@@ -4857,26 +4920,8 @@ function parseCompositionArtifactMarker(source) {
4857
4920
  if (!match) return null;
4858
4921
  const owner = match[1];
4859
4922
  const scope = match[2];
4860
- if (scope === "registry") {
4861
- if (owner) return null;
4862
- return { kind: "registry" };
4863
- }
4864
- if (scope === "contributions") {
4865
- if (owner) return null;
4866
- return { kind: "contributions" };
4867
- }
4868
- if (scope === "datastores") {
4869
- if (owner) return null;
4870
- return { kind: "datastores" };
4871
- }
4872
- if (scope === "i18n") {
4873
- if (owner) return null;
4874
- return { kind: "i18n" };
4875
- }
4876
- if (scope === "init") {
4877
- if (owner) return null;
4878
- return { kind: "init" };
4879
- }
4923
+ const ownerless = OWNERLESS_MARKER_KINDS.find((kind) => kind === scope);
4924
+ if (ownerless) return owner ? null : { kind: ownerless };
4880
4925
  if (!owner || !isValidModuleName(owner)) return null;
4881
4926
  if (scope === "route") {
4882
4927
  const nextRelativePath = match[3]?.trim();
@@ -5002,6 +5047,7 @@ function expectedMarkerKindForPath(path3) {
5002
5047
  if (path3 === MODULE_DATASTORES_GEN_PATH) return "datastores";
5003
5048
  if (path3 === MODULE_I18N_GEN_PATH) return "i18n";
5004
5049
  if (path3 === MODULE_INIT_SERVER_GEN_PATH) return "init";
5050
+ if (path3 === MODULE_SERVER_GEN_PATH) return "server";
5005
5051
  if (path3.startsWith(API_ROUTE_DIR_PREFIX) && path3.endsWith("/route.ts")) return "endpoint";
5006
5052
  if (path3.startsWith(APP_DIR_PREFIX) && !path3.startsWith(API_ROUTE_DIR_PREFIX) && PAGE_FILE_SUFFIXES.some((suffix) => path3.endsWith(suffix))) {
5007
5053
  return "route";
@@ -5089,6 +5135,25 @@ function emitModulesGenTs(registry) {
5089
5135
  ' if (entry && entry.kind !== "feature") return true;',
5090
5136
  ' return raw.split(",").includes(name);',
5091
5137
  "}",
5138
+ "",
5139
+ "/** Headless mount keys come from the platform at build time: that page is off, its Module stays on. */",
5140
+ "export function isMountHeadless(key: string): boolean {",
5141
+ ` const raw = process.env.${HEADLESS_MOUNTS_ENV};`,
5142
+ ' return raw ? raw.split(",").includes(key) : false;',
5143
+ "}",
5144
+ "",
5145
+ "/** True when `href` is a headless mount's URL or below it. */",
5146
+ "export function isHrefHeadless(href: unknown): boolean {",
5147
+ ' if (typeof href !== "string") return false;',
5148
+ " const target = href.split(/[?#]/)[0];",
5149
+ " return generatedModuleRegistry.modules.some((mod) =>",
5150
+ " mod.mounts.some((mount) => {",
5151
+ " if (!isMountHeadless(`${mod.name}:${mount.group}/${mount.segment}`)) return false;",
5152
+ " const path = `/${mount.segment}`;",
5153
+ " return target === path || target.startsWith(`${path}/`);",
5154
+ " })",
5155
+ " );",
5156
+ "}",
5092
5157
  ""
5093
5158
  ].join("\n");
5094
5159
  }
@@ -5096,6 +5161,22 @@ var STUB_PRINT_WIDTH = 100;
5096
5161
  function wrapAtPrintWidth(flat, broken) {
5097
5162
  return flat.length > STUB_PRINT_WIDTH ? broken : [flat];
5098
5163
  }
5164
+ function emitGuardLines(guards, body) {
5165
+ const operands = guards.map((guard) => `${guard.call}(${emitTsString(guard.arg)})`);
5166
+ const condition = ` if (${operands.join(" || ")})`;
5167
+ const brokenOperands = guards.flatMap((guard, i) => {
5168
+ const suffix = i < guards.length - 1 ? " ||" : "";
5169
+ return wrapAtPrintWidth(` ${operands[i]}${suffix}`, [
5170
+ ` ${guard.call}(`,
5171
+ ` ${emitTsString(guard.arg)}`,
5172
+ ` )${suffix}`
5173
+ ]);
5174
+ });
5175
+ return wrapAtPrintWidth(`${condition} ${body}`, [
5176
+ ...wrapAtPrintWidth(condition, [" if (", ...brokenOperands, " )"]),
5177
+ ` ${body}`
5178
+ ]);
5179
+ }
5099
5180
  function buildEndpointStubContent(input) {
5100
5181
  const sorted = [...input.methods].sort(
5101
5182
  (a, b) => METHOD_ORDER[a.method] - METHOD_ORDER[b.method] || a.pairKey.localeCompare(b.pairKey)
@@ -5103,24 +5184,15 @@ function buildEndpointStubContent(input) {
5103
5184
  const exports = sorted.map((m) => {
5104
5185
  const pair = emitTsString(m.pairKey);
5105
5186
  const guards = [
5106
- `!isModuleEnabled(${emitTsString(input.owner)})`,
5107
- ...input.scope.kind === "enhancement" ? [`!isModuleEnabled(${emitTsString(input.scope.peer)})`] : []
5187
+ { call: "!isModuleEnabled", arg: input.owner },
5188
+ ...input.scope.kind === "enhancement" ? [{ call: "!isModuleEnabled", arg: input.scope.peer }] : []
5108
5189
  ];
5109
- const condition = ` if (${guards.join(" || ")})`;
5110
- const notFoundBody = "return new Response(null, { status: 404 });";
5111
5190
  const message = emitTsString(`Missing endpoint handler for ${m.pairKey}`);
5112
5191
  const importPath = emitTsString(input.importModule);
5113
5192
  return [
5114
5193
  `export async function ${m.method}(request: Request, context?: unknown): Promise<Response> {`,
5115
5194
  " await initializeActiveEnhancements();",
5116
- ...wrapAtPrintWidth(`${condition} ${notFoundBody}`, [
5117
- ...wrapAtPrintWidth(condition, [
5118
- " if (",
5119
- ...guards.map((guard, i) => ` ${guard}${i < guards.length - 1 ? " ||" : ""}`),
5120
- " )"
5121
- ]),
5122
- ` ${notFoundBody}`
5123
- ]),
5195
+ ...emitGuardLines(guards, "return new Response(null, { status: 404 });"),
5124
5196
  ...wrapAtPrintWidth(` const { endpointHandlers } = await import(${importPath});`, [
5125
5197
  " const { endpointHandlers } = await import(",
5126
5198
  ` ${importPath}`,
@@ -5150,12 +5222,16 @@ function buildEndpointStubContent(input) {
5150
5222
  ].join("\n");
5151
5223
  }
5152
5224
  function buildRouteStubContent(input) {
5225
+ const guards = [
5226
+ { call: "!isModuleEnabled", arg: input.owner },
5227
+ { call: "isMountHeadless", arg: input.mountKey }
5228
+ ];
5153
5229
  const lines = [
5154
5230
  formatRouteMarker(input.owner, input.nextRelativePath),
5155
5231
  'import { notFound } from "next/navigation";',
5156
5232
  // simple-import-sort puts packages and "@/" aliases in separate groups.
5157
5233
  "",
5158
- 'import { isModuleEnabled } from "@/modules.gen";',
5234
+ 'import { isModuleEnabled, isMountHeadless } from "@/modules.gen";',
5159
5235
  `import Page from ${emitTsString(input.importModule)};`
5160
5236
  ];
5161
5237
  if (input.hasGenerateMetadata) {
@@ -5163,7 +5239,7 @@ function buildRouteStubContent(input) {
5163
5239
  `import { generateMetadata as sourceGenerateMetadata } from ${emitTsString(input.importModule)};`,
5164
5240
  "",
5165
5241
  "export async function generateMetadata(...args: Parameters<typeof sourceGenerateMetadata>) {",
5166
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) return {};`,
5242
+ ...emitGuardLines(guards, "return {};"),
5167
5243
  " return sourceGenerateMetadata(...args);",
5168
5244
  "}"
5169
5245
  );
@@ -5174,7 +5250,7 @@ function buildRouteStubContent(input) {
5174
5250
  " Parameters<typeof Page> extends [infer P, ...unknown[]] ? NonNullable<P> : Record<never, never>;",
5175
5251
  "",
5176
5252
  "export default function ModulePage(props: PageProps) {",
5177
- ` if (!isModuleEnabled(${emitTsString(input.owner)})) notFound();`,
5253
+ ...emitGuardLines(guards, "notFound();"),
5178
5254
  " return <Page {...props} />;",
5179
5255
  "}",
5180
5256
  ""
@@ -5376,7 +5452,6 @@ function planRouteStubs(input) {
5376
5452
  }
5377
5453
  for (const entry of entries) {
5378
5454
  const nextRelativePath = entry.nextRelativePath;
5379
- analyzeRouteEntryPath(nextRelativePath);
5380
5455
  const destination = normalizeAppRouterDestinationPattern(nextRelativePath);
5381
5456
  const path3 = routeEntryToAppPageFile(nextRelativePath);
5382
5457
  const existing = routeDestinationOwners.get(destination);
@@ -5391,6 +5466,8 @@ function planRouteStubs(input) {
5391
5466
  path: path3
5392
5467
  });
5393
5468
  const importModule = routeEntryImport(regMod.name, nextRelativePath);
5469
+ const analyzed = analyzeRouteEntryPath(nextRelativePath);
5470
+ const mount = mounts.find((claim) => routeEntryCoveredByMountClaim(analyzed, claim));
5394
5471
  routeStubs.push({
5395
5472
  path: path3,
5396
5473
  owner: regMod.name,
@@ -5400,6 +5477,7 @@ function planRouteStubs(input) {
5400
5477
  owner: regMod.name,
5401
5478
  nextRelativePath,
5402
5479
  importModule,
5480
+ mountKey: mountKey(regMod.name, mount),
5403
5481
  hasGenerateMetadata: entry.hasGenerateMetadata
5404
5482
  })
5405
5483
  });
@@ -5569,6 +5647,7 @@ function planCompositionArtifacts(input) {
5569
5647
  let datastoresContent;
5570
5648
  let i18nContent;
5571
5649
  let initContent;
5650
+ let serverContent;
5572
5651
  try {
5573
5652
  contributionsContent = emitModuleContributionsGenTs({
5574
5653
  registry: input.registry,
@@ -5593,6 +5672,10 @@ function planCompositionArtifacts(input) {
5593
5672
  registry: input.registry,
5594
5673
  facts: compositionEntryFacts
5595
5674
  });
5675
+ serverContent = emitModuleServerGenTs({
5676
+ registry: input.registry,
5677
+ facts: compositionEntryFacts
5678
+ });
5596
5679
  } catch (error) {
5597
5680
  return {
5598
5681
  ok: false,
@@ -5605,6 +5688,7 @@ function planCompositionArtifacts(input) {
5605
5688
  desired.set(MODULE_DATASTORES_GEN_PATH, datastoresContent);
5606
5689
  desired.set(MODULE_I18N_GEN_PATH, i18nContent);
5607
5690
  desired.set(MODULE_INIT_SERVER_GEN_PATH, initContent);
5691
+ desired.set(MODULE_SERVER_GEN_PATH, serverContent);
5608
5692
  for (const stub of stubs.endpointStubs) desired.set(stub.path, stub.content);
5609
5693
  for (const stub of stubs.routeStubs) desired.set(stub.path, stub.content);
5610
5694
  const endpointStubByPath = new Map(stubs.endpointStubs.map((stub) => [stub.path, stub]));
@@ -6875,6 +6959,7 @@ function readFailurePath(error) {
6875
6959
  return typeof error === "object" && error !== null && "path" in error ? String(error.path) : "unknown";
6876
6960
  }
6877
6961
  var CONVENTIONAL_ENTRY_KIND_PROBE_TOKEN = "conventional-entry-kind";
6962
+ var SERVER_REGISTRATIONS_FILE = "server/registrations.ts";
6878
6963
  var COMPOSITION_ARTIFACT_KIND_PROBE_TOKEN = "composition-artifact-kind";
6879
6964
  async function classifyCompositionArtifactPathKind(context, path3) {
6880
6965
  return classifySandboxPathPresenceKind(context, path3, COMPOSITION_ARTIFACT_KIND_PROBE_TOKEN);
@@ -6912,6 +6997,10 @@ async function readConventionalEntryIfRealFile(path3, kind, presences, files) {
6912
6997
  }
6913
6998
  return source;
6914
6999
  }
7000
+ function hasRealServerRegistrations(modulePath, presences) {
7001
+ const path3 = `${modulePath}/${SERVER_REGISTRATIONS_FILE}`;
7002
+ return assertRealCompositionArtifactFilePresence(path3, presences.get(path3) ?? "unreadable") === "file";
7003
+ }
6915
7004
  async function readJsonObjectFile(path3, files) {
6916
7005
  const raw = files.get(path3);
6917
7006
  let parsed;
@@ -6935,6 +7024,9 @@ async function collectCompositionEntryFacts(context, registry) {
6935
7024
  const overridesDir = `${APP_PACKAGE_DIR}/src/lib/i18n/overrides`;
6936
7025
  const localeDirs = [substrateDir, overridesDir];
6937
7026
  const conventionalPaths = [];
7027
+ const serverRegistrationPaths = registry.modules.map(
7028
+ (mod) => `${MODULES_SANDBOX_DIR}/${mod.name}/${SERVER_REGISTRATIONS_FILE}`
7029
+ );
6938
7030
  for (const mod of registry.modules) {
6939
7031
  const modulePath = `${MODULES_SANDBOX_DIR}/${mod.name}`;
6940
7032
  localeDirs.push(`${modulePath}/i18n`);
@@ -6963,7 +7055,7 @@ async function collectCompositionEntryFacts(context, registry) {
6963
7055
  const [conventionalPresences, localePresences] = await Promise.all([
6964
7056
  classifySandboxPathPresenceKindMany(
6965
7057
  context,
6966
- conventionalPaths,
7058
+ [...conventionalPaths, ...serverRegistrationPaths],
6967
7059
  CONVENTIONAL_ENTRY_KIND_PROBE_TOKEN
6968
7060
  ),
6969
7061
  classifySandboxPathPresenceKindMany(context, localePaths, LOCALE_JSON_KIND_PROBE_TOKEN)
@@ -7025,6 +7117,7 @@ async function collectCompositionEntryFacts(context, registry) {
7025
7117
  );
7026
7118
  }
7027
7119
  }
7120
+ const hasServerRegistrations = hasRealServerRegistrations(modulePath, conventionalPresences);
7028
7121
  const rootLocales = localesByDir.get(`${modulePath}/i18n`);
7029
7122
  if (rootLocales.length > 0 && !rootLocales.includes("en")) {
7030
7123
  throw new Error(
@@ -7064,6 +7157,7 @@ async function collectCompositionEntryFacts(context, registry) {
7064
7157
  moduleName: mod.name,
7065
7158
  hasRootContributions,
7066
7159
  hasRootSlots,
7160
+ hasServerRegistrations,
7067
7161
  rootLocales,
7068
7162
  enhancements
7069
7163
  });
@@ -7267,6 +7361,7 @@ async function reconcileCompositionArtifacts(context, options = {}) {
7267
7361
  MODULE_DATASTORES_GEN_PATH,
7268
7362
  MODULE_I18N_GEN_PATH,
7269
7363
  MODULE_INIT_SERVER_GEN_PATH,
7364
+ MODULE_SERVER_GEN_PATH,
7270
7365
  ...existingGeneratedPaths,
7271
7366
  ...Object.keys(existingAppPages),
7272
7367
  ...Object.keys(existingAppRoutes)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/compose",
3
- "version": "0.6.0",
4
- "description": "Regenerates a Stardeck app's Module-rail artifacts — the five src/*.gen.ts registries and every route/endpoint stub — from src/modules/*/module.json",
3
+ "version": "0.8.0",
4
+ "description": "Regenerates a Stardeck app's Module-rail artifacts — the six src/*.gen.ts registries and every route/endpoint stub — from src/modules/*/module.json",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",