@segmentstream/app 0.0.0-stage → 0.38.1

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 (125) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +2 -0
  3. package/README.md +1236 -2
  4. package/dist/agent.d.ts +56 -0
  5. package/dist/agent.js +203 -0
  6. package/dist/app.d.ts +82 -0
  7. package/dist/app.js +434 -0
  8. package/dist/browser-auth-capability.d.ts +2 -0
  9. package/dist/browser-auth-capability.js +2 -0
  10. package/dist/browser-revision.d.ts +19 -0
  11. package/dist/browser-revision.js +140 -0
  12. package/dist/channel-protocol.d.ts +16 -0
  13. package/dist/channel-protocol.js +17 -0
  14. package/dist/channel-session.d.ts +22 -0
  15. package/dist/channel-session.js +89 -0
  16. package/dist/config.d.ts +53 -0
  17. package/dist/config.js +112 -0
  18. package/dist/context.d.ts +10 -0
  19. package/dist/context.js +1 -0
  20. package/dist/cookies.d.ts +56 -0
  21. package/dist/cookies.js +131 -0
  22. package/dist/data.d.ts +233 -0
  23. package/dist/data.js +206 -0
  24. package/dist/decision-pages.d.ts +14 -0
  25. package/dist/decision-pages.js +87 -0
  26. package/dist/deployment.d.ts +30 -0
  27. package/dist/deployment.js +72 -0
  28. package/dist/errors.d.ts +60 -0
  29. package/dist/errors.js +123 -0
  30. package/dist/federation.d.ts +24 -0
  31. package/dist/federation.js +613 -0
  32. package/dist/groups.d.ts +95 -0
  33. package/dist/groups.js +280 -0
  34. package/dist/handlers.d.ts +10 -0
  35. package/dist/handlers.js +161 -0
  36. package/dist/index.d.ts +46 -0
  37. package/dist/index.js +29 -0
  38. package/dist/jobs.d.ts +49 -0
  39. package/dist/jobs.js +40 -0
  40. package/dist/legacy.d.ts +77 -0
  41. package/dist/legacy.js +57 -0
  42. package/dist/logo.d.ts +1 -0
  43. package/dist/logo.js +25 -0
  44. package/dist/me.d.ts +42 -0
  45. package/dist/me.js +54 -0
  46. package/dist/navigation/build.d.ts +68 -0
  47. package/dist/navigation/build.js +290 -0
  48. package/dist/navigation/cli.d.ts +19 -0
  49. package/dist/navigation/cli.js +37 -0
  50. package/dist/navigation/generate.d.ts +43 -0
  51. package/dist/navigation/generate.js +138 -0
  52. package/dist/navigation/next.d.ts +10 -0
  53. package/dist/navigation/next.js +18 -0
  54. package/dist/navigation-protocol.d.ts +55 -0
  55. package/dist/navigation-protocol.js +298 -0
  56. package/dist/neon.d.ts +101 -0
  57. package/dist/neon.js +539 -0
  58. package/dist/oauth-completion.d.ts +6 -0
  59. package/dist/oauth-completion.js +106 -0
  60. package/dist/oauth.d.ts +59 -0
  61. package/dist/oauth.js +129 -0
  62. package/dist/ontology-names.d.ts +10 -0
  63. package/dist/ontology-names.js +38 -0
  64. package/dist/ontology-transport.d.ts +34 -0
  65. package/dist/ontology-transport.js +130 -0
  66. package/dist/ontology-wire.d.ts +66 -0
  67. package/dist/ontology-wire.js +355 -0
  68. package/dist/ontology.d.ts +381 -0
  69. package/dist/ontology.js +367 -0
  70. package/dist/pages.d.ts +6 -0
  71. package/dist/pages.js +6 -0
  72. package/dist/parameters.d.ts +55 -0
  73. package/dist/parameters.js +118 -0
  74. package/dist/permissions.d.ts +19 -0
  75. package/dist/permissions.js +55 -0
  76. package/dist/pkce.d.ts +8 -0
  77. package/dist/pkce.js +40 -0
  78. package/dist/proxy.d.ts +25 -0
  79. package/dist/proxy.js +135 -0
  80. package/dist/request-timing.d.ts +6 -0
  81. package/dist/request-timing.js +53 -0
  82. package/dist/return-to.d.ts +2 -0
  83. package/dist/return-to.js +24 -0
  84. package/dist/server.d.ts +11 -0
  85. package/dist/server.js +80 -0
  86. package/dist/sessions.d.ts +27 -0
  87. package/dist/sessions.js +72 -0
  88. package/dist/sign-in.d.ts +37 -0
  89. package/dist/sign-in.js +95 -0
  90. package/dist/storage.d.ts +79 -0
  91. package/dist/storage.js +180 -0
  92. package/dist/telegram-launch.d.ts +7 -0
  93. package/dist/telegram-launch.js +134 -0
  94. package/dist/telegram.d.ts +2 -0
  95. package/dist/telegram.js +6 -0
  96. package/dist/tokens.d.ts +51 -0
  97. package/dist/tokens.js +70 -0
  98. package/dist/ui/agent-connect.d.ts +18 -0
  99. package/dist/ui/agent-connect.js +23 -0
  100. package/dist/ui/components.d.ts +27 -0
  101. package/dist/ui/components.js +71 -0
  102. package/dist/ui/hooks.d.ts +19 -0
  103. package/dist/ui/hooks.js +56 -0
  104. package/dist/ui/index.d.ts +22 -0
  105. package/dist/ui/index.js +15 -0
  106. package/dist/ui/navigation.d.ts +5 -0
  107. package/dist/ui/navigation.js +8 -0
  108. package/dist/ui/segmentstream-logo.d.ts +2 -0
  109. package/dist/ui/segmentstream-logo.js +7 -0
  110. package/dist/ui/sign-in-check.d.ts +14 -0
  111. package/dist/ui/sign-in-check.js +41 -0
  112. package/dist/ui/sign-in.d.ts +17 -0
  113. package/dist/ui/sign-in.js +65 -0
  114. package/dist/ui/store.d.ts +80 -0
  115. package/dist/ui/store.js +280 -0
  116. package/dist/ui/styles.css +249 -0
  117. package/dist/ui/telegram-sign-in.d.ts +16 -0
  118. package/dist/ui/telegram-sign-in.js +21 -0
  119. package/dist/ui/use-browser-sign-in.d.ts +52 -0
  120. package/dist/ui/use-browser-sign-in.js +169 -0
  121. package/dist/ui/use-channel-sign-in.d.ts +10 -0
  122. package/dist/ui/use-channel-sign-in.js +118 -0
  123. package/dist/ui/use-sign-in.d.ts +109 -0
  124. package/dist/ui/use-sign-in.js +304 -0
  125. package/package.json +95 -4
@@ -0,0 +1,290 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
+ import { navigationRoutesMayOverlap, parsePageCatalog } from "../navigation-protocol.js";
6
+ import { generatePageCatalog, normalizePageRoute, pageCatalogCoverage, PageCatalogBuildError } from "./generate.js";
7
+ import { PAGE_CATALOG_PLUGIN } from "./next.js";
8
+ export * from "./generate.js";
9
+ export { withPageCatalog } from "./next.js";
10
+ export const PAGE_CATALOG_PATH = ".segmentstream/navigation/page-catalog.json";
11
+ export const PAGE_CATALOG_CONTEXT_PATH = ".segmentstream/navigation/build-context.json";
12
+ export const PAGE_CATALOG_COMPILED_PATH = ".segmentstream/navigation/compiled.json";
13
+ function error(code, message) {
14
+ throw new PageCatalogBuildError(code, message);
15
+ }
16
+ function cleanupFailure(primary, cleanup) {
17
+ const message = primary instanceof Error ? primary.message : String(primary);
18
+ throw new AggregateError([primary, cleanup], `${message} (page catalog cleanup also failed).`, { cause: primary });
19
+ }
20
+ async function json(path) {
21
+ return JSON.parse(await readFile(path, "utf8"));
22
+ }
23
+ async function atomicJson(path, value) {
24
+ await mkdir(dirname(path), { recursive: true });
25
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
26
+ try {
27
+ await writeFile(temporary, `${JSON.stringify(value)}\n`, { flag: "wx" });
28
+ await rename(temporary, path);
29
+ }
30
+ catch (cause) {
31
+ try {
32
+ await rm(temporary, { force: true });
33
+ }
34
+ catch (cleanup) {
35
+ cleanupFailure(cause, cleanup);
36
+ }
37
+ throw cause;
38
+ }
39
+ }
40
+ /** Read only routing source, never dependencies, generated artifacts, or page modules via import. */
41
+ export async function readPageCatalogFiles(projectDir) {
42
+ const files = new Map();
43
+ async function walk(directory) {
44
+ let entries;
45
+ try {
46
+ entries = await readdir(directory, { withFileTypes: true });
47
+ }
48
+ catch (cause) {
49
+ if (cause.code === "ENOENT")
50
+ return;
51
+ throw cause;
52
+ }
53
+ for (const entry of entries) {
54
+ const path = join(directory, entry.name);
55
+ if (entry.isSymbolicLink())
56
+ error("unsupported_page_symlink", `Page catalog source must not contain symbolic links: ${relative(projectDir, path)}`);
57
+ if (entry.isDirectory())
58
+ await walk(path);
59
+ else if (entry.isFile())
60
+ files.set(relative(projectDir, path).split("\\").join("/"), await readFile(path, "utf8"));
61
+ }
62
+ }
63
+ for (const root of ["app", "src/app", "pages", "src/pages"])
64
+ await walk(join(projectDir, root));
65
+ for (const file of ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.mts", "next.config.cjs"]) {
66
+ try {
67
+ files.set(file, await readFile(join(projectDir, file), "utf8"));
68
+ }
69
+ catch (cause) {
70
+ if (cause.code !== "ENOENT")
71
+ throw cause;
72
+ }
73
+ }
74
+ return files;
75
+ }
76
+ function fingerprint(files, routing) {
77
+ return createHash("sha256").update(JSON.stringify({ files: [...files].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0), routing })).digest("hex");
78
+ }
79
+ function routingFrom(config) {
80
+ return { basePath: config.basePath ?? "", trailingSlash: config.trailingSlash ?? false,
81
+ pageExtensions: config.pageExtensions ?? ["tsx", "ts", "jsx", "js"] };
82
+ }
83
+ function installedNext(projectDir) {
84
+ const require = createRequire(join(resolve(projectDir), "package.json"));
85
+ const version = require("next/package.json").version;
86
+ return { require, version };
87
+ }
88
+ function supportedNext(version) {
89
+ if (!/^16\.\d+\.\d+$/u.test(version))
90
+ error("unsupported_next_version", `Page discovery supports stable Next.js 16.x; found ${version}.`);
91
+ }
92
+ async function configuration(projectDir) {
93
+ const next = installedNext(projectDir);
94
+ const loader = next.require("next/dist/server/config.js");
95
+ const config = await loader.default("phase-production-build", resolve(projectDir));
96
+ return { ...next, config, enabled: Boolean(config.compiler?.runAfterProductionCompile &&
97
+ Reflect.get(config.compiler.runAfterProductionCompile, PAGE_CATALOG_PLUGIN) === true) };
98
+ }
99
+ /** Prove static matches exactly and compare complete path shapes for dynamic pages. */
100
+ export function verifyPageCatalogRouting(catalog, inventory, frameworkInterceptions = []) {
101
+ const signature = (rule) => JSON.stringify([rule.source, rule.regex, rule.destination, rule.has, rule.missing]);
102
+ const interceptions = new Set(frameworkInterceptions.map(signature));
103
+ const earlyRules = [...inventory.redirects.filter((rule) => !rule.internal),
104
+ ...inventory.rewrites.beforeFiles.filter((rule) => !interceptions.has(signature(rule)))];
105
+ for (const page of catalog.pages) {
106
+ // Next checks afterFiles rewrites before its dynamic filesystem route table.
107
+ const rules = page.parameters.length === 0 ? earlyRules : [...earlyRules, ...inventory.rewrites.afterFiles];
108
+ for (const rule of rules) {
109
+ let overlaps;
110
+ if (page.parameters.length === 0)
111
+ overlaps = new RegExp(rule.regex, "i").test(page.route);
112
+ else {
113
+ overlaps = navigationRoutesMayOverlap(page.route, rule.source);
114
+ }
115
+ if (overlaps)
116
+ error("shadowed_page_route", `${page.ref}: a redirect or rewrite (${rule.source}) may intercept this page. Resolve its direct-load routing before advertising it.`);
117
+ }
118
+ }
119
+ }
120
+ async function configuredRouting(next) {
121
+ const load = next.require("next/dist/lib/load-custom-routes.js");
122
+ const build = next.require("next/dist/lib/build-custom-route.js");
123
+ const routes = await load.default(next.config);
124
+ return { redirects: routes.redirects.map((rule) => build.buildCustomRoute("redirect", rule, ["/_next"])),
125
+ rewrites: { beforeFiles: routes.rewrites.beforeFiles.map((rule) => build.buildCustomRoute("rewrite", rule)),
126
+ afterFiles: routes.rewrites.afterFiles, fallback: routes.rewrites.fallback } };
127
+ }
128
+ async function invalidate(projectDir) {
129
+ await Promise.all([PAGE_CATALOG_PATH, PAGE_CATALOG_CONTEXT_PATH, PAGE_CATALOG_COMPILED_PATH].map((path) => rm(join(projectDir, path), { force: true })));
130
+ }
131
+ async function invalidateAfterFailure(projectDir, primary) {
132
+ try {
133
+ await invalidate(projectDir);
134
+ }
135
+ catch (cleanup) {
136
+ cleanupFailure(primary, cleanup);
137
+ }
138
+ throw primary;
139
+ }
140
+ /** Run before parallel app/ontology builds, using the ontology evaluated from the same immutable source. */
141
+ export async function preparePageCatalog(input) {
142
+ const projectDir = resolve(input.projectDir);
143
+ await invalidate(projectDir);
144
+ try {
145
+ const files = await readPageCatalogFiles(projectDir);
146
+ const next = await configuration(projectDir);
147
+ if (!next.enabled) {
148
+ if ([...files.keys()].some((file) => file.endsWith("/page.navigation.json"))) {
149
+ error("page_catalog_integration_required", "page.navigation.json requires withPageCatalog(nextConfig) from @segmentstream/app/navigation/next in next.config. Enable it before publishing page metadata.");
150
+ }
151
+ return { enabled: false, coverage: pageCatalogCoverage(files, routingFrom(next.config)) };
152
+ }
153
+ supportedNext(next.version);
154
+ if (!input.ontology || !Array.isArray(input.ontology.entities) || typeof input.ontology.hash !== "string") {
155
+ error("page_catalog_ontology_required", "Page discovery requires the ontology evaluated from this workspace source.");
156
+ }
157
+ const routing = routingFrom(next.config);
158
+ if (next.config.useFileSystemPublicRoutes === false || next.config.i18n) {
159
+ error("unsupported_routing_configuration", "Page discovery requires App Router filesystem routes without Pages Router i18n configuration.");
160
+ }
161
+ const catalog = generatePageCatalog({ files, routing, ontology: input.ontology, sourceHash: input.sourceHash });
162
+ verifyPageCatalogRouting(catalog, await configuredRouting(next));
163
+ const context = { version: 1, ontology: { hash: input.ontology.hash,
164
+ entities: input.ontology.entities.map(({ name }) => ({ name })) }, sourceHash: input.sourceHash, routing,
165
+ sourceFingerprint: fingerprint(files, routing), distDir: next.config.distDir, nextVersion: next.version };
166
+ await atomicJson(join(projectDir, PAGE_CATALOG_CONTEXT_PATH), context);
167
+ await atomicJson(join(projectDir, PAGE_CATALOG_PATH), catalog);
168
+ return { enabled: true, catalog, catalogPath: join(projectDir, PAGE_CATALOG_PATH), coverage: pageCatalogCoverage(files, routing) };
169
+ }
170
+ catch (cause) {
171
+ return invalidateAfterFailure(projectDir, cause);
172
+ }
173
+ }
174
+ async function prepared(projectDir, routing) {
175
+ let context;
176
+ let catalog;
177
+ try {
178
+ context = await json(join(projectDir, PAGE_CATALOG_CONTEXT_PATH));
179
+ catalog = parsePageCatalog(await json(join(projectDir, PAGE_CATALOG_PATH)));
180
+ }
181
+ catch (cause) {
182
+ throw new PageCatalogBuildError("page_catalog_not_prepared", "Page discovery is enabled but its prepared catalog is missing or invalid. Run segmentstream-page-catalog generate with the evaluated ontology and immutable source identity before building.", { cause });
183
+ }
184
+ if (context.version !== 1)
185
+ error("page_catalog_not_prepared", "Unsupported page catalog preparation context; regenerate it.");
186
+ const files = await readPageCatalogFiles(projectDir);
187
+ if (fingerprint(files, routing ?? context.routing) !== context.sourceFingerprint) {
188
+ error("page_catalog_source_mismatch", "App routing or source changed after page catalog preparation; regenerate from the build's immutable source.");
189
+ }
190
+ const regenerated = generatePageCatalog({ files, routing: context.routing, ontology: context.ontology, sourceHash: context.sourceHash });
191
+ if (JSON.stringify(regenerated) !== JSON.stringify(catalog))
192
+ error("page_catalog_source_mismatch", "Prepared page catalog differs from the current source; regenerate it.");
193
+ return { context, catalog, files };
194
+ }
195
+ function verifyCompiledRoutes(catalog, manifest, routing) {
196
+ // Compiled keys have already decoded escaped private prefixes; they are not filesystem paths.
197
+ const direct = Object.keys(manifest).filter((path) => path.endsWith("/page") &&
198
+ !path.includes("/@") && !/\/\(\.{1,3}\)/u.test(path)).map((path) => {
199
+ const pathname = `/${path.split("/").filter(Boolean).slice(0, -1)
200
+ .filter((segment) => !(segment.startsWith("(") && segment.endsWith(")"))).join("/")}`.replace(/%5F/g, "_");
201
+ return normalizePageRoute(pathname, routing);
202
+ });
203
+ for (const page of catalog.pages) {
204
+ if (!direct.includes(page.route))
205
+ error("page_catalog_route_mismatch", `${page.ref}: no direct-load UI page exists in Next's compiled app route inventory.`);
206
+ }
207
+ }
208
+ /** Compiler-stage safety check; final verification below must also follow the complete build command. */
209
+ export async function verifyCompiledPageCatalog(input) {
210
+ try {
211
+ const { context, catalog } = await prepared(input.projectDir, input.routing === undefined ? undefined : routingFrom(input.routing));
212
+ const next = installedNext(input.projectDir);
213
+ supportedNext(next.version);
214
+ if (context.nextVersion !== next.version)
215
+ error("page_catalog_source_mismatch", "The installed Next.js version changed after catalog preparation.");
216
+ const manifest = await readFile(join(input.distDir, "server/app-paths-manifest.json"), "utf8");
217
+ verifyCompiledRoutes(catalog, JSON.parse(manifest), context.routing);
218
+ await atomicJson(join(input.projectDir, PAGE_CATALOG_COMPILED_PATH), { revision: catalog.revision,
219
+ manifestHash: createHash("sha256").update(manifest).digest("hex") });
220
+ }
221
+ catch (cause) {
222
+ return invalidateAfterFailure(input.projectDir, cause);
223
+ }
224
+ }
225
+ /** Verify final resolved routes after any app build command, including custom commands. */
226
+ export async function verifyPageCatalogBuild(input) {
227
+ const projectDir = resolve(input.projectDir);
228
+ try {
229
+ const next = await configuration(projectDir);
230
+ if (!next.enabled) {
231
+ const files = await readPageCatalogFiles(projectDir);
232
+ let wasPrepared = false;
233
+ try {
234
+ await readFile(join(projectDir, PAGE_CATALOG_CONTEXT_PATH));
235
+ wasPrepared = true;
236
+ }
237
+ catch (cause) {
238
+ if (cause.code !== "ENOENT")
239
+ throw cause;
240
+ }
241
+ if (wasPrepared)
242
+ error("page_catalog_integration_required", "The prepared catalog requires withPageCatalog in the final build configuration, including an enabled empty catalog.");
243
+ if ([...files.keys()].some((file) => file.endsWith("/page.navigation.json"))) {
244
+ error("page_catalog_integration_required", "Page metadata requires withPageCatalog(nextConfig) before it can be published.");
245
+ }
246
+ return { enabled: false };
247
+ }
248
+ supportedNext(next.version);
249
+ const { context, catalog } = await prepared(projectDir, routingFrom(next.config));
250
+ if (context.distDir !== next.config.distDir || context.nextVersion !== next.version)
251
+ error("page_catalog_source_mismatch", "Next.js build configuration changed after catalog preparation.");
252
+ const distDir = resolve(projectDir, context.distDir);
253
+ if (!(await readFile(join(distDir, "BUILD_ID"), "utf8")).trim())
254
+ error("page_catalog_build_incomplete", "Next.js build did not complete.");
255
+ const compiledSource = await readFile(join(distDir, "server/app-paths-manifest.json"), "utf8");
256
+ const compiled = JSON.parse(compiledSource);
257
+ const proof = await json(join(projectDir, PAGE_CATALOG_COMPILED_PATH));
258
+ if (proof.revision !== catalog.revision || proof.manifestHash !== createHash("sha256").update(compiledSource).digest("hex")) {
259
+ error("page_catalog_build_incomplete", "The current Next.js build did not verify the prepared page catalog; run the complete app build command.");
260
+ }
261
+ verifyCompiledRoutes(catalog, compiled, context.routing);
262
+ const resolvedRoutes = await json(join(distDir, "app-path-routes-manifest.json"));
263
+ const manifest = await json(join(distDir, "routes-manifest.json"));
264
+ const required = await json(join(distDir, "required-server-files.json"));
265
+ if (Reflect.get(required.config.experimental ?? {}, "isExperimentalCompile") === true) {
266
+ error("page_catalog_build_incomplete", "A compile-only Next.js build must finish generation before final page catalog verification.");
267
+ }
268
+ if (manifest.version !== 3 || manifest.basePath !== context.routing.basePath ||
269
+ JSON.stringify(routingFrom(required.config)) !== JSON.stringify(context.routing)) {
270
+ error("page_catalog_route_mismatch", "Final Next.js routing configuration does not match the prepared page catalog.");
271
+ }
272
+ for (const page of catalog.pages) {
273
+ const resolvedPage = Object.entries(resolvedRoutes).some(([appPath, pathname]) => appPath.endsWith("/page") && !appPath.includes("/@") && !/\/\(\.{1,3}\)/u.test(appPath) &&
274
+ normalizePageRoute(pathname.replace(/%5F/g, "_"), context.routing) === page.route && Object.hasOwn(compiled, appPath));
275
+ const served = [...manifest.staticRoutes, ...manifest.dynamicRoutes].some((route) => normalizePageRoute(route.page, context.routing) === page.route);
276
+ if (!resolvedPage || !served)
277
+ error("page_catalog_route_mismatch", `${page.ref}: the final Next.js route inventory does not serve this UI page.`);
278
+ }
279
+ // Reconstruct only Next's own context-dependent interception rules from its resolved inventory.
280
+ // A direct page stays addressable while an unannotated modal variant handles client navigation.
281
+ const interceptionAdapter = next.require("next/dist/lib/generate-interception-routes-rewrites.js");
282
+ const customRouteAdapter = next.require("next/dist/lib/build-custom-route.js");
283
+ const interceptions = interceptionAdapter.generateInterceptionRoutesRewrites([...new Set(Object.values(resolvedRoutes))], context.routing.basePath ?? "").map((rule) => customRouteAdapter.buildCustomRoute("rewrite", rule));
284
+ verifyPageCatalogRouting(catalog, manifest, interceptions);
285
+ return { enabled: true, revision: catalog.revision, pages: catalog.pages.length };
286
+ }
287
+ catch (cause) {
288
+ return invalidateAfterFailure(projectDir, cause);
289
+ }
290
+ }
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ export declare function runPageCatalogCommand(args: readonly string[]): Promise<{
3
+ enabled: false;
4
+ revision?: undefined;
5
+ pages?: undefined;
6
+ } | {
7
+ enabled: true;
8
+ revision: string;
9
+ pages: number;
10
+ } | {
11
+ catalogPath?: string | undefined;
12
+ revision?: string | undefined;
13
+ enabled: boolean;
14
+ coverage: {
15
+ pages: number;
16
+ annotated: number;
17
+ unannotated: number;
18
+ };
19
+ }>;
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { realpathSync, writeSync } from "node:fs";
4
+ import { pathToFileURL } from "node:url";
5
+ import { preparePageCatalog, verifyPageCatalogBuild } from "./build.js";
6
+ export async function runPageCatalogCommand(args) {
7
+ const [command, ...options] = args;
8
+ const values = new Map();
9
+ for (let index = 0; index < options.length; index += 2) {
10
+ const key = options[index];
11
+ const value = options[index + 1];
12
+ if (!["--project-dir", "--ontology", "--source-hash"].includes(key) || !value || values.has(key)) {
13
+ throw new Error("Usage: segmentstream-page-catalog generate --project-dir <app> --ontology <manifest.json> --source-hash <identity> | verify --project-dir <app>");
14
+ }
15
+ values.set(key, value);
16
+ }
17
+ const projectDir = values.get("--project-dir");
18
+ if (!projectDir)
19
+ throw new Error("--project-dir is required.");
20
+ if (command === "verify" && values.size === 1)
21
+ return verifyPageCatalogBuild({ projectDir });
22
+ if (command !== "generate" || !values.has("--ontology") || !values.has("--source-hash")) {
23
+ throw new Error("generate requires --ontology and --source-hash; verify accepts only --project-dir.");
24
+ }
25
+ const ontology = JSON.parse(await readFile(values.get("--ontology"), "utf8"));
26
+ const result = await preparePageCatalog({ projectDir, ontology, sourceHash: values.get("--source-hash") });
27
+ return { enabled: result.enabled, coverage: result.coverage,
28
+ ...(result.enabled ? { catalogPath: result.catalogPath, revision: result.catalog.revision } : {}) };
29
+ }
30
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
31
+ runPageCatalogCommand(process.argv.slice(2)).then((result) => {
32
+ writeSync(1, `${JSON.stringify(result)}\n`);
33
+ }).catch((cause) => {
34
+ writeSync(2, `${cause instanceof Error ? cause.message : String(cause)}\n`);
35
+ process.exitCode = 1;
36
+ });
37
+ }
@@ -0,0 +1,43 @@
1
+ import { type PageCatalogV1 } from "../navigation-protocol.js";
2
+ export { NavigationCatalogError, parsePageCatalog } from "../navigation-protocol.js";
3
+ export type { PageCatalogV1, PageEntry, PageMetadata } from "../navigation-protocol.js";
4
+ export declare class PageCatalogBuildError extends Error {
5
+ readonly code: string;
6
+ readonly name = "PageCatalogBuildError";
7
+ constructor(code: string, message: string, options?: ErrorOptions);
8
+ }
9
+ export interface PageCatalogRouting {
10
+ readonly basePath?: string;
11
+ readonly trailingSlash?: boolean;
12
+ readonly pageExtensions?: readonly string[];
13
+ }
14
+ export interface PageCatalogOntology {
15
+ readonly hash: string;
16
+ readonly entities: readonly {
17
+ readonly name: string;
18
+ }[];
19
+ }
20
+ export interface GeneratePageCatalogInput {
21
+ readonly files: ReadonlyMap<string, string>;
22
+ readonly routing?: PageCatalogRouting;
23
+ readonly ontology: PageCatalogOntology;
24
+ readonly sourceHash: string;
25
+ }
26
+ export interface PageRoute {
27
+ readonly file: string;
28
+ readonly appPath: string;
29
+ readonly pathname: string;
30
+ readonly route: string;
31
+ readonly parallel: boolean;
32
+ readonly intercepted: boolean;
33
+ }
34
+ export declare function normalizePageRoute(pathname: string, routing?: PageCatalogRouting): string;
35
+ /** Extract App Router page files without importing or executing page components. */
36
+ export declare function extractPageRoutes(files: ReadonlyMap<string, string>, routing?: PageCatalogRouting): readonly PageRoute[];
37
+ /** Pure, deterministic Node build-time compiler. Source strings are never executed. */
38
+ export declare function generatePageCatalog(input: GeneratePageCatalogInput): PageCatalogV1;
39
+ export declare function pageCatalogCoverage(files: ReadonlyMap<string, string>, routing?: PageCatalogRouting): {
40
+ pages: number;
41
+ annotated: number;
42
+ unannotated: number;
43
+ };
@@ -0,0 +1,138 @@
1
+ import { createPageCatalog, NavigationCatalogError } from "../navigation-protocol.js";
2
+ export { NavigationCatalogError, parsePageCatalog } from "../navigation-protocol.js";
3
+ export class PageCatalogBuildError extends Error {
4
+ code;
5
+ name = "PageCatalogBuildError";
6
+ constructor(code, message, options) {
7
+ super(message, options);
8
+ this.code = code;
9
+ }
10
+ }
11
+ function fail(code, message) {
12
+ throw new PageCatalogBuildError(code, message);
13
+ }
14
+ export function normalizePageRoute(pathname, routing = {}) {
15
+ const basePath = routing.basePath ?? "";
16
+ if (basePath !== "" && (!basePath.startsWith("/") || basePath.endsWith("/") ||
17
+ /[?#\\\s\[\]]/u.test(basePath) || basePath.includes("//"))) {
18
+ fail("unsupported_routing_configuration", "basePath must be an absolute static path without a trailing slash.");
19
+ }
20
+ const route = `${basePath}${pathname === "/" ? "" : pathname}` || "/";
21
+ // Next leaves file-like paths and root .well-known paths without an appended slash.
22
+ const slashException = pathname !== "/" && (/\/[^/[\]]*\.[^/[\]]*$/u.test(route) || /^\/\.well-known(?:\/|$)/iu.test(route));
23
+ return routing.trailingSlash && !slashException && !route.endsWith("/") ? `${route}/` : route;
24
+ }
25
+ /** Extract App Router page files without importing or executing page components. */
26
+ export function extractPageRoutes(files, routing = {}) {
27
+ const paths = [...files.keys()].sort();
28
+ for (const file of paths) {
29
+ if (file.startsWith("/") || file.includes("\\") || file.split("/").some((part) => part === "." || part === "..")) {
30
+ fail("invalid_source_path", `Expected an app-root relative POSIX file path: ${file}`);
31
+ }
32
+ }
33
+ const root = paths.some((file) => file.startsWith("app/")) ? "app/" : "src/app/";
34
+ const extensions = routing.pageExtensions ?? ["tsx", "ts", "jsx", "js"];
35
+ if (extensions.length === 0 || extensions.some((extension) => !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/u.test(extension))) {
36
+ fail("unsupported_routing_configuration", "pageExtensions must contain supported file extensions.");
37
+ }
38
+ const routes = [];
39
+ for (const file of paths) {
40
+ if (!file.startsWith(root) || !extensions.some((extension) => file.endsWith(`/page.${extension}`)))
41
+ continue;
42
+ const segments = file.slice(root.length).split("/").slice(0, -1);
43
+ if (segments.some((segment) => segment.startsWith("_")))
44
+ continue;
45
+ const intercepted = segments.some((segment) => /^\(\.{1,3}\)/u.test(segment));
46
+ const visible = segments.filter((segment) => !segment.startsWith("@") && !(segment.startsWith("(") && segment.endsWith(")")));
47
+ const pathname = `/${visible.join("/")}`.replace(/%5F/g, "_");
48
+ routes.push({ file, appPath: `/${[...segments, "page"].join("/")}`, pathname,
49
+ route: normalizePageRoute(pathname, routing), parallel: segments.some((segment) => segment.startsWith("@")), intercepted });
50
+ }
51
+ return routes;
52
+ }
53
+ function metadata(source, file) {
54
+ let value;
55
+ try {
56
+ value = JSON.parse(source);
57
+ }
58
+ catch (cause) {
59
+ throw new PageCatalogBuildError("invalid_page_metadata", `${file}: invalid JSON.`, { cause });
60
+ }
61
+ if (typeof value !== "object" || value === null || Array.isArray(value) ||
62
+ Object.keys(value).some((key) => !["title", "description", "entities", "parameters", "query"].includes(key))) {
63
+ fail("invalid_page_metadata", `${file}: expected page metadata with title, description, entities and optional hints; routes are generated.`);
64
+ }
65
+ const data = value;
66
+ for (const key of ["parameters", "query"]) {
67
+ if (data[key] !== undefined && (typeof data[key] !== "object" || data[key] === null || Array.isArray(data[key]))) {
68
+ fail("invalid_page_metadata", `${file}: ${key} must be a hint map.`);
69
+ }
70
+ }
71
+ // Field values and all wire limits are validated by the canonical ontology contract.
72
+ return value;
73
+ }
74
+ function parameters(pathname, hints, file) {
75
+ const result = [];
76
+ for (const segment of pathname.split("/")) {
77
+ const match = /^(?:\[\[\.\.\.([^\[\]]+)\]\]|\[\.\.\.([^\[\]]+)\]|\[([^\[\]]+)\])$/u.exec(segment);
78
+ if (!match)
79
+ continue;
80
+ const name = match[1] ?? match[2] ?? match[3];
81
+ result.push({ name, kind: match[3] === undefined ? "catch_all" : "segment", required: match[1] === undefined,
82
+ ...(hints && Object.hasOwn(hints, name) ? { description: hints[name] } : {}) });
83
+ }
84
+ for (const name of Object.keys(hints ?? {})) {
85
+ if (!result.some((parameter) => parameter.name === name)) {
86
+ fail("unknown_page_parameter", `${file}: parameter hint ${JSON.stringify(name)} does not name a route parameter.`);
87
+ }
88
+ }
89
+ return result;
90
+ }
91
+ /** Pure, deterministic Node build-time compiler. Source strings are never executed. */
92
+ export function generatePageCatalog(input) {
93
+ const routes = extractPageRoutes(input.files, input.routing);
94
+ const entities = new Set(input.ontology.entities.map((entity) => entity.name));
95
+ const pages = [];
96
+ for (const [file, source] of [...input.files].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
97
+ if (!file.endsWith("/page.navigation.json"))
98
+ continue;
99
+ const directory = file.slice(0, -"page.navigation.json".length);
100
+ const matches = routes.filter((route) => route.file.slice(0, route.file.lastIndexOf("/") + 1) === directory);
101
+ if (matches.length === 0)
102
+ fail("orphan_page_metadata", `${file}: no routable page exists beside this annotation under the active App Router root and pageExtensions.`);
103
+ if (matches.length > 1)
104
+ fail("duplicate_page_route", `${file}: more than one configured page file exists beside this annotation.`);
105
+ const route = matches[0];
106
+ if (route.intercepted)
107
+ fail("unsupported_intercepted_page", `${file}: intercepted UI has no independent direct-load URL; annotate its ordinary destination or containing page.`);
108
+ if (route.parallel && !routes.some((other) => !other.parallel && !other.intercepted && other.pathname === route.pathname)) {
109
+ fail("unsupported_parallel_page", `${file}: no ordinary direct-load page establishes this parallel variant's URL; annotate its containing page.`);
110
+ }
111
+ const data = metadata(source, file);
112
+ const entry = { ref: `page:${route.route}`, route: route.route, title: data.title,
113
+ description: data.description, entities: data.entities, parameters: parameters(route.pathname, data.parameters, file),
114
+ query: data.query ?? {} };
115
+ // Delegate wire validation to the canonical contract before checking references.
116
+ let normalized;
117
+ try {
118
+ normalized = createPageCatalog([entry], { ontologyHash: input.ontology.hash, sourceHash: input.sourceHash }).pages[0];
119
+ }
120
+ catch (cause) {
121
+ if (cause instanceof NavigationCatalogError) {
122
+ throw new PageCatalogBuildError(cause.code, `${file}: ${cause.message}`, { cause });
123
+ }
124
+ throw cause;
125
+ }
126
+ for (const name of normalized.entities) {
127
+ if (!entities.has(name))
128
+ fail("unknown_page_entity", `${file}: unknown ontology entity ${JSON.stringify(name)}.`);
129
+ }
130
+ pages.push(normalized);
131
+ }
132
+ return createPageCatalog(pages, { ontologyHash: input.ontology.hash, sourceHash: input.sourceHash });
133
+ }
134
+ export function pageCatalogCoverage(files, routing) {
135
+ const routes = extractPageRoutes(files, routing).filter((route) => !route.parallel && !route.intercepted);
136
+ const annotated = routes.filter((route) => files.has(`${route.file.slice(0, route.file.lastIndexOf("/") + 1)}page.navigation.json`));
137
+ return { pages: routes.length, annotated: annotated.length, unannotated: routes.length - annotated.length };
138
+ }
@@ -0,0 +1,10 @@
1
+ import type { NextConfig } from "next";
2
+ export declare const PAGE_CATALOG_PLUGIN: unique symbol;
3
+ type ConfigFunction = (phase: string, context: {
4
+ defaultConfig: NextConfig;
5
+ }) => NextConfig | Promise<NextConfig>;
6
+ /** Opt into page discovery without changing page components or replacing other Next plugins. */
7
+ export declare function withPageCatalog(config: NextConfig): NextConfig;
8
+ export declare function withPageCatalog(config: ConfigFunction): ConfigFunction;
9
+ export declare function withPageCatalog(config: Promise<NextConfig>): Promise<NextConfig>;
10
+ export {};
@@ -0,0 +1,18 @@
1
+ import { verifyCompiledPageCatalog } from "./build.js";
2
+ export const PAGE_CATALOG_PLUGIN = Symbol.for("@segmentstream/app/page-catalog");
3
+ export function withPageCatalog(config) {
4
+ if (typeof config === "function") {
5
+ return async (phase, context) => withPageCatalog(await config(phase, context));
6
+ }
7
+ if ("then" in config)
8
+ return Promise.resolve(config).then((resolved) => withPageCatalog(resolved));
9
+ const previous = config.compiler?.runAfterProductionCompile;
10
+ if (previous && Reflect.get(previous, PAGE_CATALOG_PLUGIN) === true)
11
+ return config;
12
+ const runAfterProductionCompile = async (metadata) => {
13
+ await previous?.(metadata);
14
+ await verifyCompiledPageCatalog({ projectDir: metadata.projectDir, distDir: metadata.distDir });
15
+ };
16
+ Object.defineProperty(runAfterProductionCompile, PAGE_CATALOG_PLUGIN, { value: true });
17
+ return { ...config, compiler: { ...config.compiler, runAfterProductionCompile } };
18
+ }
@@ -0,0 +1,55 @@
1
+ /** Workspace-authored metadata beside an ordinary UI page. Routes are generated, never authored. */
2
+ export interface PageMetadata {
3
+ readonly title: string;
4
+ readonly entities: readonly string[];
5
+ readonly description: string;
6
+ readonly parameters?: Readonly<Record<string, string>>;
7
+ readonly query?: Readonly<Record<string, string>>;
8
+ }
9
+ export interface PageEntry {
10
+ readonly ref: string;
11
+ readonly route: string;
12
+ readonly title: string;
13
+ readonly description: string;
14
+ readonly entities: readonly string[];
15
+ readonly parameters: readonly {
16
+ readonly name: string;
17
+ readonly kind: "segment" | "catch_all";
18
+ readonly required: boolean;
19
+ readonly description?: string;
20
+ }[];
21
+ readonly query: Readonly<Record<string, string>>;
22
+ }
23
+ export interface PageCatalogV1 {
24
+ readonly version: 1;
25
+ readonly revision: string;
26
+ readonly pages: readonly PageEntry[];
27
+ }
28
+ export declare const NAVIGATION_CATALOG_LIMITS: Readonly<{
29
+ pages: 1000;
30
+ bytes: number;
31
+ title: 120;
32
+ description: 2000;
33
+ hint: 512;
34
+ associations: 32;
35
+ hints: 32;
36
+ responseBytes: number;
37
+ }>;
38
+ /**
39
+ * Whether a generated page and a routing source can share a path. Compare complete segment
40
+ * shapes for ordinary parameters, optional parameters and catch-alls; custom regex sources
41
+ * remain conservative when their static prefixes cannot prove disjointness.
42
+ */
43
+ export declare function navigationRoutesMayOverlap(route: string, source: string): boolean;
44
+ export declare class NavigationCatalogError extends Error {
45
+ readonly code: "invalid_page_catalog" | "page_catalog_too_large";
46
+ readonly name = "NavigationCatalogError";
47
+ constructor(code: "invalid_page_catalog" | "page_catalog_too_large", message: string);
48
+ }
49
+ /** Validate and copy an untrusted artifact. Entity existence is checked against the serving ontology. */
50
+ export declare function parsePageCatalog(value: unknown): PageCatalogV1;
51
+ /** Generate an immutable catalog identity from normalized metadata, ontology, and source identity. */
52
+ export declare function createPageCatalog(pages: readonly PageEntry[], context: {
53
+ readonly ontologyHash: string;
54
+ readonly sourceHash: string;
55
+ }): PageCatalogV1;