@fluojs/react 0.1.0 → 0.2.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.
Files changed (59) hide show
  1. package/README.ko.md +629 -16
  2. package/README.md +644 -25
  3. package/dist/decorators.d.ts +3 -3
  4. package/dist/decorators.d.ts.map +1 -1
  5. package/dist/decorators.js +10 -4
  6. package/dist/diagnostics.d.ts +110 -0
  7. package/dist/diagnostics.d.ts.map +1 -0
  8. package/dist/diagnostics.js +180 -0
  9. package/dist/error-representation.d.ts +28 -0
  10. package/dist/error-representation.d.ts.map +1 -0
  11. package/dist/error-representation.js +28 -0
  12. package/dist/experimental/rsc-flight-response.d.ts.map +1 -1
  13. package/dist/experimental/rsc-flight-response.js +2 -6
  14. package/dist/experimental/server-functions-server.js +1 -1
  15. package/dist/experimental/server-functions-types.d.ts +1 -1
  16. package/dist/experimental/server-functions-types.d.ts.map +1 -1
  17. package/dist/index.d.ts +16 -4
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -1
  20. package/dist/module.d.ts +8 -2
  21. package/dist/module.d.ts.map +1 -1
  22. package/dist/module.js +31 -8
  23. package/dist/page-catalog.d.ts +29 -0
  24. package/dist/page-catalog.d.ts.map +1 -0
  25. package/dist/page-catalog.js +46 -0
  26. package/dist/page-metadata.d.ts +58 -0
  27. package/dist/page-metadata.d.ts.map +1 -0
  28. package/dist/page-metadata.js +139 -0
  29. package/dist/page-renderer.d.ts +24 -0
  30. package/dist/page-renderer.d.ts.map +1 -0
  31. package/dist/page-renderer.js +18 -0
  32. package/dist/page-result.d.ts +16 -0
  33. package/dist/page-result.d.ts.map +1 -0
  34. package/dist/page-result.js +101 -0
  35. package/dist/render-diagnostics.d.ts +25 -0
  36. package/dist/render-diagnostics.d.ts.map +1 -0
  37. package/dist/render-diagnostics.js +94 -0
  38. package/dist/render-policy-metadata.d.ts +40 -0
  39. package/dist/render-policy-metadata.d.ts.map +1 -0
  40. package/dist/render-policy-metadata.js +136 -0
  41. package/dist/render-policy.d.ts +87 -0
  42. package/dist/render-policy.d.ts.map +1 -0
  43. package/dist/render-policy.js +203 -0
  44. package/dist/render-stream.d.ts +1 -1
  45. package/dist/render-stream.d.ts.map +1 -1
  46. package/dist/render-stream.js +80 -15
  47. package/dist/render.d.ts +12 -3
  48. package/dist/render.d.ts.map +1 -1
  49. package/dist/render.js +70 -76
  50. package/dist/server-entry.d.ts +13 -1
  51. package/dist/server-entry.d.ts.map +1 -1
  52. package/dist/server-entry.js +21 -11
  53. package/dist/typegen-artifact.d.ts +9 -0
  54. package/dist/typegen-artifact.d.ts.map +1 -0
  55. package/dist/typegen-artifact.js +116 -0
  56. package/dist/typegen.d.ts +41 -0
  57. package/dist/typegen.d.ts.map +1 -0
  58. package/dist/typegen.js +162 -0
  59. package/package.json +11 -6
package/dist/render.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createReactRenderDiagnostics } from './render-diagnostics.js';
1
2
  import { collectReadableStream, pipeReadableStream, throwIfReactRequestAborted } from './render-stream.js';
2
3
  const HTML_CONTENT_TYPE = 'text/html; charset=utf-8';
3
4
 
@@ -9,7 +10,7 @@ const HTML_CONTENT_TYPE = 'text/html; charset=utf-8';
9
10
 
10
11
  /** Options for rendering a React entry into one fluo response. */
11
12
 
12
- /** Minimal fluo request context needed to render one React HTML response. */
13
+ /** Request-scoped fluo context available to page renderers and render policy components. */
13
14
 
14
15
  function applyEntryHeaders(entry, requestContext) {
15
16
  for (const [name, value] of Object.entries(entry.headers)) {
@@ -97,58 +98,6 @@ function createReactDomRenderOptions(options) {
97
98
  } : {})
98
99
  };
99
100
  }
100
- function createRecoverableErrorContext(errorInfo, requestContext) {
101
- return {
102
- ...(errorInfo !== undefined ? {
103
- errorInfo
104
- } : {}),
105
- request: requestContext.request,
106
- ...(requestContext.requestId !== undefined ? {
107
- requestId: requestContext.requestId
108
- } : {})
109
- };
110
- }
111
- function reportRecoverableError(entry, requestContext, event) {
112
- const hook = entry.onRecoverableError;
113
- if (!hook) {
114
- return;
115
- }
116
- try {
117
- hook(event.error, createRecoverableErrorContext(event.errorInfo, requestContext));
118
- } catch (error) {
119
- if (error instanceof Error) {
120
- return;
121
- }
122
- }
123
- }
124
- function reportRecoverableErrors(entry, requestContext, events) {
125
- for (const event of events) {
126
- reportRecoverableError(entry, requestContext, event);
127
- }
128
- }
129
- async function writeReactStream(plan) {
130
- const {
131
- applySuccessMetadata,
132
- entry,
133
- pendingRecoverableErrors,
134
- requestContext,
135
- stream
136
- } = plan;
137
- const responseStream = requestContext.response.stream;
138
- if (!responseStream) {
139
- const body = await collectReadableStream(stream, requestContext.request);
140
- throwIfReactRequestAborted(requestContext.request);
141
- applySuccessMetadata();
142
- reportRecoverableErrors(entry, requestContext, pendingRecoverableErrors);
143
- await requestContext.response.send(body);
144
- return;
145
- }
146
- applySuccessMetadata();
147
- requestContext.response.committed = true;
148
- responseStream.flush?.();
149
- reportRecoverableErrors(entry, requestContext, pendingRecoverableErrors);
150
- await pipeReadableStream(stream, responseStream, requestContext.request);
151
- }
152
101
  async function defaultRenderToReadableStream(node, options) {
153
102
  const {
154
103
  renderToReadableStream
@@ -156,6 +105,32 @@ async function defaultRenderToReadableStream(node, options) {
156
105
  return renderToReadableStream(node, createReactDomRenderOptions(options));
157
106
  }
158
107
 
108
+ /**
109
+ * Buffers a React server entry without mutating or committing a framework response.
110
+ *
111
+ * @param entry React server entry whose node and hydration assets are rendered.
112
+ * @param requestContext HTTP-owned request and abort surfaces.
113
+ * @param renderToReadableStream Optional Web Streams renderer override.
114
+ * @returns Complete HTML bytes after the render finishes without errors.
115
+ */
116
+ export async function renderReactServerEntryToBytes(entry, requestContext, renderToReadableStream = defaultRenderToReadableStream) {
117
+ throwIfReactRequestAborted(requestContext.request);
118
+ let hasRenderError = false;
119
+ let renderError;
120
+ const stream = await renderToReadableStream(entry.node, createReactReadableStreamRenderOptions(entry, requestContext, error => {
121
+ if (!hasRenderError) {
122
+ hasRenderError = true;
123
+ renderError = error;
124
+ }
125
+ }));
126
+ const body = await collectReadableStream(stream, requestContext.request);
127
+ throwIfReactRequestAborted(requestContext.request);
128
+ if (hasRenderError) {
129
+ throw renderError;
130
+ }
131
+ return body;
132
+ }
133
+
159
134
  /**
160
135
  * Renders a React server entry to one fluo HTML response using Web Streams SSR.
161
136
  *
@@ -167,35 +142,54 @@ async function defaultRenderToReadableStream(node, options) {
167
142
  * @throws RequestAbortedError before buffered responses commit when the request aborts.
168
143
  */
169
144
  export async function renderReactResponse(entry, requestContext, options = {}) {
170
- throwIfReactRequestAborted(requestContext.request);
145
+ const diagnostics = createReactRenderDiagnostics(entry, requestContext);
171
146
  const renderToReadableStream = options.renderToReadableStream ?? defaultRenderToReadableStream;
172
147
  const pendingRecoverableErrors = [];
173
148
  let shellReady = false;
174
- const stream = await renderToReadableStream(entry.node, createReactReadableStreamRenderOptions(entry, requestContext, (error, errorInfo) => {
175
- const event = errorInfo !== undefined ? {
176
- error,
177
- errorInfo
178
- } : {
179
- error
180
- };
181
- if (shellReady) {
182
- reportRecoverableError(entry, requestContext, event);
183
- return;
184
- }
185
- pendingRecoverableErrors.push(event);
186
- }));
187
- shellReady = true;
188
- throwIfReactRequestAborted(requestContext.request);
149
+ let stream;
150
+ try {
151
+ throwIfReactRequestAborted(requestContext.request);
152
+ stream = await renderToReadableStream(entry.node, createReactReadableStreamRenderOptions(entry, requestContext, (error, errorInfo) => {
153
+ const event = errorInfo !== undefined ? {
154
+ error,
155
+ errorInfo
156
+ } : {
157
+ error
158
+ };
159
+ if (shellReady) {
160
+ diagnostics.reportRecoverableError(event);
161
+ return;
162
+ }
163
+ pendingRecoverableErrors.push(event);
164
+ }));
165
+ shellReady = true;
166
+ throwIfReactRequestAborted(requestContext.request);
167
+ } catch (error) {
168
+ throw diagnostics.preservePreCommitShellError(error);
169
+ }
189
170
  const applySuccessMetadata = () => {
190
171
  options.applySuccessResponseMetadata?.();
191
172
  applyEntryStatus(entry, requestContext);
192
173
  applyEntryHeaders(entry, requestContext);
193
174
  };
194
- await writeReactStream({
195
- applySuccessMetadata,
196
- entry,
197
- pendingRecoverableErrors,
198
- requestContext,
199
- stream
200
- });
175
+ const responseStream = requestContext.response.stream;
176
+ if (!responseStream) {
177
+ let body;
178
+ try {
179
+ body = await collectReadableStream(stream, requestContext.request);
180
+ throwIfReactRequestAborted(requestContext.request);
181
+ } catch (error) {
182
+ throw diagnostics.preservePreCommitShellError(error);
183
+ }
184
+ applySuccessMetadata();
185
+ diagnostics.reportRecoverableErrors(pendingRecoverableErrors);
186
+ await requestContext.response.send(body);
187
+ return;
188
+ }
189
+ applySuccessMetadata();
190
+ requestContext.response.committed = true;
191
+ responseStream.flush?.();
192
+ diagnostics.reportRecoverableErrors(pendingRecoverableErrors);
193
+ await pipeReadableStream(stream, responseStream, requestContext.request);
194
+ diagnostics.reportRequestAbort();
201
195
  }
@@ -1,5 +1,6 @@
1
- import type { FrameworkRequest } from '@fluojs/http';
1
+ import type { FrameworkRequest } from '@fluojs/http/portable';
2
2
  import { type ReactNode } from 'react';
3
+ import type { ReactSsrDiagnosticCode, ReactSsrDiagnosticPhase } from './diagnostics.js';
3
4
  /** Header values applied before a React server entry starts streaming. */
4
5
  export type ReactServerEntryHeaders = Readonly<Record<string, string | readonly string[]>>;
5
6
  /** Build-produced asset names mapped to public URLs shared by server render and client hydration. */
@@ -17,8 +18,12 @@ export type ReactBootstrapScriptDescriptor = {
17
18
  export type ReactBootstrapAsset = string | ReactBootstrapScriptDescriptor;
18
19
  /** Recoverable React render error details reported after the shell can stream. */
19
20
  export type ReactRecoverableErrorContext = {
21
+ /** Stable machine-readable diagnostic code for recoverable rendering failures. */
22
+ readonly code: ReactSsrDiagnosticCode;
20
23
  /** React-provided error metadata, such as a component stack, when available. */
21
24
  readonly errorInfo?: unknown;
25
+ /** Stable SSR lifecycle phase for recoverable rendering failures. */
26
+ readonly phase: ReactSsrDiagnosticPhase;
22
27
  /** Framework request being rendered. */
23
28
  readonly request: FrameworkRequest;
24
29
  /** Adapter-provided request id, when available. */
@@ -70,6 +75,13 @@ export type ReactServerEntry = {
70
75
  /** HTTP status to apply before streaming starts. Defaults to the current response status or `200`. */
71
76
  readonly status?: number;
72
77
  };
78
+ /**
79
+ * Returns whether a value is a React server entry created or branded by the stable SSR seam.
80
+ *
81
+ * @param value Candidate response value.
82
+ * @returns Whether the value carries the React server entry brand.
83
+ */
84
+ export declare function isReactServerEntry(value: unknown): value is ReactServerEntry;
73
85
  /**
74
86
  * Creates a runtime-neutral React server entry for streamed HTML rendering.
75
87
  *
@@ -1 +1 @@
1
- {"version":3,"file":"server-entry.d.ts","sourceRoot":"","sources":["../src/server-entry.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server-entry.d.ts","sourceRoot":"","sources":["../src/server-entry.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,EAAgC,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAErE,OAAO,KAAK,EACV,sBAAsB,EACtB,uBAAuB,EACxB,MAAM,kBAAkB,CAAC;AAS1B,0EAA0E;AAC1E,MAAM,MAAM,uBAAuB,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;AAE3F,qGAAqG;AACrG,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAE7D,6FAA6F;AAC7F,MAAM,MAAM,8BAA8B,GAAG;IAC3C,sDAAsD;IACtD,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,4EAA4E;IAC5E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,gFAAgF;IAChF,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B,CAAC;AAEF,8DAA8D;AAC9D,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,8BAA8B,CAAC;AAE1E,kFAAkF;AAClF,MAAM,MAAM,4BAA4B,GAAG;IACzC,kFAAkF;IAClF,QAAQ,CAAC,IAAI,EAAE,sBAAsB,CAAC;IACtC,gFAAgF;IAChF,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,KAAK,EAAE,uBAAuB,CAAC;IACxC,wCAAwC;IACxC,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC;IACnC,mDAAmD;IACnD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B,CAAC;AAEF,4EAA4E;AAC5E,MAAM,MAAM,4BAA4B,GAAG,CACzC,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,4BAA4B,KAClC,IAAI,CAAC;AAEV,mDAAmD;AACnD,MAAM,MAAM,uBAAuB,GAAG;IACpC,gGAAgG;IAChG,QAAQ,CAAC,QAAQ,CAAC,EAAE,aAAa,CAAC;IAClC,mFAAmF;IACnF,QAAQ,CAAC,gBAAgB,CAAC,EAAE,SAAS,mBAAmB,EAAE,CAAC;IAC3D,4FAA4F;IAC5F,QAAQ,CAAC,sBAAsB,CAAC,EAAE,MAAM,CAAC;IACzC,oFAAoF;IACpF,QAAQ,CAAC,gBAAgB,CAAC,EAAE,SAAS,mBAAmB,EAAE,CAAC;IAC3D,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,CAAC,EAAE,uBAAuB,CAAC;IAC3C,kEAAkE;IAClE,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC,mEAAmE;IACnE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,8EAA8E;IAC9E,QAAQ,CAAC,kBAAkB,CAAC,EAAE,4BAA4B,CAAC;IAC3D,sGAAsG;IACtG,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEF,kGAAkG;AAClG,MAAM,MAAM,gBAAgB,GAAG;IAC7B,gGAAgG;IAChG,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC;IACjC,mFAAmF;IACnF,QAAQ,CAAC,gBAAgB,EAAE,SAAS,mBAAmB,EAAE,CAAC;IAC1D,4FAA4F;IAC5F,QAAQ,CAAC,sBAAsB,CAAC,EAAE,MAAM,CAAC;IACzC,oFAAoF;IACpF,QAAQ,CAAC,gBAAgB,EAAE,SAAS,mBAAmB,EAAE,CAAC;IAC1D,2EAA2E;IAC3E,QAAQ,CAAC,OAAO,EAAE,uBAAuB,CAAC;IAC1C,kEAAkE;IAClE,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC,oEAAoE;IACpE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,mEAAmE;IACnE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,8EAA8E;IAC9E,QAAQ,CAAC,kBAAkB,CAAC,EAAE,4BAA4B,CAAC;IAC3D,sGAAsG;IACtG,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,gBAAgB,CAI5E;AAyED;;;;;;;;;;;;GAYG;AACH,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,SAAS,EACf,OAAO,GAAE,uBAA4B,GACpC,gBAAgB,CA0BlB"}
@@ -1,5 +1,6 @@
1
+ import { registerFrameworkResponseWriter } from '@fluojs/http/internal';
1
2
  import { cloneElement, isValidElement } from 'react';
2
- const responseWriterKey = Symbol.for('fluo.http.responseWriter');
3
+ const serverEntryKey = Symbol.for('fluo.react.serverEntry');
3
4
 
4
5
  /** Header values applied before a React server entry starts streaming. */
5
6
 
@@ -17,6 +18,15 @@ const responseWriterKey = Symbol.for('fluo.http.responseWriter');
17
18
 
18
19
  /** Runtime-neutral React server entry rendered to streamed HTML by `renderReactResponse(...)`. */
19
20
 
21
+ /**
22
+ * Returns whether a value is a React server entry created or branded by the stable SSR seam.
23
+ *
24
+ * @param value Candidate response value.
25
+ * @returns Whether the value carries the React server entry brand.
26
+ */
27
+ export function isReactServerEntry(value) {
28
+ return typeof value === 'object' && value !== null && Reflect.get(value, serverEntryKey) === true;
29
+ }
20
30
  function cloneAssetMap(assetMap) {
21
31
  const cloned = {};
22
32
  Object.setPrototypeOf(cloned, null);
@@ -111,16 +121,16 @@ export function createReactServerEntry(node, options = {}) {
111
121
  status: options.status
112
122
  } : {})
113
123
  };
114
- Object.defineProperty(entry, responseWriterKey, {
124
+ Object.defineProperty(entry, serverEntryKey, {
115
125
  enumerable: false,
116
- value: async context => {
117
- const {
118
- renderReactResponse
119
- } = await import('./render.js');
120
- await renderReactResponse(entry, context.requestContext, {
121
- applySuccessResponseMetadata: context.applySuccessResponseMetadata
122
- });
123
- }
126
+ value: true
127
+ });
128
+ return registerFrameworkResponseWriter(entry, async context => {
129
+ const {
130
+ renderReactResponse
131
+ } = await import('./render.js');
132
+ await renderReactResponse(entry, context.requestContext, {
133
+ applySuccessResponseMetadata: context.applySuccessResponseMetadata
134
+ });
124
135
  });
125
- return entry;
126
136
  }
@@ -0,0 +1,9 @@
1
+ import type { ReactPageCatalogEntry } from './page-catalog.js';
2
+ /**
3
+ * Parses the route catalog encoded in one canonical generated React page artifact.
4
+ *
5
+ * @param source Generated artifact source to inspect.
6
+ * @returns Parsed catalog entries, or `undefined` when the artifact body is malformed.
7
+ */
8
+ export declare function parseGeneratedReactPageCatalog(source: string): readonly ReactPageCatalogEntry[] | undefined;
9
+ //# sourceMappingURL=typegen-artifact.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"typegen-artifact.d.ts","sourceRoot":"","sources":["../src/typegen-artifact.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AA4F/D;;;;;GAKG;AACH,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,qBAAqB,EAAE,GAAG,SAAS,CAiB3G"}
@@ -0,0 +1,116 @@
1
+ const JSON_STRING_SOURCE = '"(?:\\\\.|[^"\\\\])*"';
2
+ const PATH_LINE_PATTERN = new RegExp(`^ readonly (${JSON_STRING_SOURCE}): (${JSON_STRING_SOURCE});$`, 'u');
3
+ const PARAM_OPEN_PATTERN = new RegExp(`^ readonly (${JSON_STRING_SOURCE}): \\{$`, 'u');
4
+ const PARAM_LINE_PATTERN = new RegExp(`^ readonly (${JSON_STRING_SOURCE}): string;$`, 'u');
5
+ const PARAM_UNDEFINED_PATTERN = new RegExp(`^ readonly (${JSON_STRING_SOURCE}): undefined;$`, 'u');
6
+ function parseJsonString(value) {
7
+ try {
8
+ const parsed = JSON.parse(value);
9
+ return typeof parsed === 'string' ? parsed : undefined;
10
+ } catch {
11
+ return undefined;
12
+ }
13
+ }
14
+ function readSection(lines, opening, closing) {
15
+ const start = lines.indexOf(opening);
16
+ if (start < 0) {
17
+ return undefined;
18
+ }
19
+ const end = lines.indexOf(closing, start + 1);
20
+ return end < 0 ? undefined : lines.slice(start + 1, end);
21
+ }
22
+ function parsePaths(lines) {
23
+ if (lines.includes('export type ReactPagePathById = Readonly<Record<never, never>>;')) {
24
+ return new Map();
25
+ }
26
+ const section = readSection(lines, 'export interface ReactPagePathById {', '}');
27
+ if (section === undefined || section.length === 0) {
28
+ return undefined;
29
+ }
30
+ const paths = new Map();
31
+ for (const line of section) {
32
+ const match = PATH_LINE_PATTERN.exec(line);
33
+ const id = match?.[1] === undefined ? undefined : parseJsonString(match[1]);
34
+ const path = match?.[2] === undefined ? undefined : parseJsonString(match[2]);
35
+ if (id === undefined || path === undefined || paths.has(id)) {
36
+ return undefined;
37
+ }
38
+ paths.set(id, path);
39
+ }
40
+ return paths;
41
+ }
42
+ function parseParams(lines) {
43
+ if (lines.includes('export type ReactPageParamsById = Readonly<Record<never, never>>;')) {
44
+ return new Map();
45
+ }
46
+ const section = readSection(lines, 'export interface ReactPageParamsById {', '}');
47
+ if (section === undefined || section.length === 0) {
48
+ return undefined;
49
+ }
50
+ const paramsById = new Map();
51
+ for (let index = 0; index < section.length; index += 1) {
52
+ const line = section[index] ?? '';
53
+ const undefinedMatch = PARAM_UNDEFINED_PATTERN.exec(line);
54
+ const undefinedId = undefinedMatch?.[1] === undefined ? undefined : parseJsonString(undefinedMatch[1]);
55
+ if (undefinedId !== undefined) {
56
+ if (paramsById.has(undefinedId)) {
57
+ return undefined;
58
+ }
59
+ paramsById.set(undefinedId, []);
60
+ continue;
61
+ }
62
+ const openMatch = PARAM_OPEN_PATTERN.exec(line);
63
+ const id = openMatch?.[1] === undefined ? undefined : parseJsonString(openMatch[1]);
64
+ if (id === undefined || paramsById.has(id)) {
65
+ return undefined;
66
+ }
67
+ const params = [];
68
+ index += 1;
69
+ while (index < section.length && section[index] !== ' };') {
70
+ const paramMatch = PARAM_LINE_PATTERN.exec(section[index] ?? '');
71
+ const param = paramMatch?.[1] === undefined ? undefined : parseJsonString(paramMatch[1]);
72
+ if (param === undefined) {
73
+ return undefined;
74
+ }
75
+ params.push(param);
76
+ index += 1;
77
+ }
78
+ if (section[index] !== ' };' || params.length === 0) {
79
+ return undefined;
80
+ }
81
+ paramsById.set(id, params);
82
+ }
83
+ return paramsById;
84
+ }
85
+
86
+ /**
87
+ * Parses the route catalog encoded in one canonical generated React page artifact.
88
+ *
89
+ * @param source Generated artifact source to inspect.
90
+ * @returns Parsed catalog entries, or `undefined` when the artifact body is malformed.
91
+ */
92
+ export function parseGeneratedReactPageCatalog(source) {
93
+ const lines = source.replaceAll('\r\n', '\n').split('\n');
94
+ const paths = parsePaths(lines);
95
+ const paramsById = parseParams(lines);
96
+ if (paths === undefined || paramsById === undefined || paths.size !== paramsById.size) {
97
+ return undefined;
98
+ }
99
+ const catalog = [];
100
+ for (const [id, path] of paths) {
101
+ const params = paramsById.get(id);
102
+ if (params === undefined) {
103
+ return undefined;
104
+ }
105
+ catalog.push({
106
+ handler: '',
107
+ id,
108
+ kind: 'react-page',
109
+ method: 'GET',
110
+ params,
111
+ path,
112
+ router: ''
113
+ });
114
+ }
115
+ return catalog;
116
+ }
@@ -0,0 +1,41 @@
1
+ import type { ReactPageCatalogEntry } from './page-catalog.js';
2
+ /** Current schema version embedded in generated React page type artifacts. */
3
+ export declare const REACT_PAGE_TYPEGEN_ARTIFACT_VERSION = 1;
4
+ /** Structural classification of an existing React page type artifact. */
5
+ export type ReactPageTypeArtifactInspection = {
6
+ readonly status: 'malformed';
7
+ } | {
8
+ readonly status: 'unsupported-version';
9
+ readonly version: number;
10
+ } | {
11
+ readonly status: 'valid';
12
+ readonly version: number;
13
+ };
14
+ /** Stable diagnostic codes emitted by React page type generation. */
15
+ export declare const REACT_PAGE_TYPEGEN_ERROR_CODES: {
16
+ readonly VERSIONED_ROUTE_UNSUPPORTED: "react-page-typegen-versioned-route-unsupported";
17
+ };
18
+ /** Diagnostic code emitted by React page type generation. */
19
+ export type ReactPageTypegenErrorCode = (typeof REACT_PAGE_TYPEGEN_ERROR_CODES)[keyof typeof REACT_PAGE_TYPEGEN_ERROR_CODES];
20
+ /** Typed failure raised when a catalog entry cannot be represented by path-only output. */
21
+ export declare class ReactPageTypegenError extends Error {
22
+ readonly routeId: string;
23
+ readonly name = "ReactPageTypegenError";
24
+ readonly code: ReactPageTypegenErrorCode;
25
+ constructor(routeId: string);
26
+ }
27
+ /**
28
+ * Classifies generated React page type source before tooling compares its bytes.
29
+ *
30
+ * @param source Existing artifact source read from disk.
31
+ * @returns Its current, malformed, or unsupported-version structural status.
32
+ */
33
+ export declare function inspectReactPageTypeArtifact(source: string): ReactPageTypeArtifactInspection;
34
+ /**
35
+ * Generates path-only TypeScript route declarations and href builders from a React page catalog.
36
+ *
37
+ * @param catalog Bootstrap-resolved React page catalog entries.
38
+ * @returns Deterministic TypeScript source for application-owned route tooling.
39
+ */
40
+ export declare function generateReactPageTypes(catalog: readonly ReactPageCatalogEntry[]): string;
41
+ //# sourceMappingURL=typegen.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"typegen.d.ts","sourceRoot":"","sources":["../src/typegen.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAG/D,8EAA8E;AAC9E,eAAO,MAAM,mCAAmC,IAAI,CAAC;AAMrD,yEAAyE;AACzE,MAAM,MAAM,+BAA+B,GACvC;IAAE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;CAAE,GAChC;IAAE,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACpE;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAE3D,qEAAqE;AACrE,eAAO,MAAM,8BAA8B;;CAEjC,CAAC;AAEX,6DAA6D;AAC7D,MAAM,MAAM,yBAAyB,GAAG,CAAC,OAAO,8BAA8B,CAAC,CAAC,MAAM,OAAO,8BAA8B,CAAC,CAAC;AAE7H,2FAA2F;AAC3F,qBAAa,qBAAsB,SAAQ,KAAK;IAIlC,QAAQ,CAAC,OAAO,EAAE,MAAM;IAHpC,QAAQ,CAAC,IAAI,2BAA2B;IACxC,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAA8D;gBAEjF,OAAO,EAAE,MAAM;CAGrC;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAAC,MAAM,EAAE,MAAM,GAAG,+BAA+B,CAuB5F;AAqGD;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,SAAS,qBAAqB,EAAE,GAAG,MAAM,CAqCxF"}
@@ -0,0 +1,162 @@
1
+ import { parseGeneratedReactPageCatalog } from './typegen-artifact.js';
2
+
3
+ /** Current schema version embedded in generated React page type artifacts. */
4
+ export const REACT_PAGE_TYPEGEN_ARTIFACT_VERSION = 1;
5
+ const GENERATED_BANNER = `/* Generated by @fluojs/react/typegen. Artifact version: ${REACT_PAGE_TYPEGEN_ARTIFACT_VERSION}. Do not edit manually. */`;
6
+ const GENERATED_FOOTER = '/* End generated @fluojs/react/typegen artifact. */';
7
+ const GENERATED_BANNER_PATTERN = /^\/\* Generated by @fluojs\/react\/typegen\. Artifact version: (0|[1-9][0-9]{0,8})\. Do not edit manually\. \*\/\r?\n/u;
8
+
9
+ /** Structural classification of an existing React page type artifact. */
10
+
11
+ /** Stable diagnostic codes emitted by React page type generation. */
12
+ export const REACT_PAGE_TYPEGEN_ERROR_CODES = {
13
+ VERSIONED_ROUTE_UNSUPPORTED: 'react-page-typegen-versioned-route-unsupported'
14
+ };
15
+
16
+ /** Diagnostic code emitted by React page type generation. */
17
+
18
+ /** Typed failure raised when a catalog entry cannot be represented by path-only output. */
19
+ export class ReactPageTypegenError extends Error {
20
+ name = 'ReactPageTypegenError';
21
+ code = REACT_PAGE_TYPEGEN_ERROR_CODES.VERSIONED_ROUTE_UNSUPPORTED;
22
+ constructor(routeId) {
23
+ super(`Versioned React page route "${routeId}" is unsupported by path-only typegen.`);
24
+ this.routeId = routeId;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Classifies generated React page type source before tooling compares its bytes.
30
+ *
31
+ * @param source Existing artifact source read from disk.
32
+ * @returns Its current, malformed, or unsupported-version structural status.
33
+ */
34
+ export function inspectReactPageTypeArtifact(source) {
35
+ const match = GENERATED_BANNER_PATTERN.exec(source);
36
+ const rawVersion = match?.[1];
37
+ if (rawVersion === undefined) {
38
+ return {
39
+ status: 'malformed'
40
+ };
41
+ }
42
+ const version = Number(rawVersion);
43
+ if (version !== REACT_PAGE_TYPEGEN_ARTIFACT_VERSION) {
44
+ return {
45
+ status: 'unsupported-version',
46
+ version
47
+ };
48
+ }
49
+ const normalizedSource = source.replaceAll('\r\n', '\n');
50
+ const parsedCatalog = parseGeneratedReactPageCatalog(normalizedSource);
51
+ if (parsedCatalog === undefined || !normalizedSource.endsWith(`${GENERATED_FOOTER}\n`) || generateReactPageTypes(parsedCatalog) !== normalizedSource) {
52
+ return {
53
+ status: 'malformed'
54
+ };
55
+ }
56
+ return {
57
+ status: 'valid',
58
+ version
59
+ };
60
+ }
61
+ function stringLiteral(value) {
62
+ return JSON.stringify(value);
63
+ }
64
+ function renderPathMap(catalog) {
65
+ if (catalog.length === 0) {
66
+ return ['export type ReactPagePathById = Readonly<Record<never, never>>;'];
67
+ }
68
+ return ['export interface ReactPagePathById {', ...catalog.map(entry => ` readonly ${stringLiteral(entry.id)}: ${stringLiteral(entry.path)};`), '}'];
69
+ }
70
+ function uniqueParams(entry) {
71
+ return [...new Set(entry.params)];
72
+ }
73
+ function renderParamsMap(catalog) {
74
+ if (catalog.length === 0) {
75
+ return ['export type ReactPageParamsById = Readonly<Record<never, never>>;'];
76
+ }
77
+ const lines = ['export interface ReactPageParamsById {'];
78
+ for (const entry of catalog) {
79
+ const params = uniqueParams(entry);
80
+ if (params.length === 0) {
81
+ lines.push(` readonly ${stringLiteral(entry.id)}: undefined;`);
82
+ continue;
83
+ }
84
+ lines.push(` readonly ${stringLiteral(entry.id)}: {`);
85
+ lines.push(...params.map(param => ` readonly ${stringLiteral(param)}: string;`));
86
+ lines.push(' };');
87
+ }
88
+ lines.push('}');
89
+ return lines;
90
+ }
91
+ function renderHrefExpression(entry) {
92
+ const params = uniqueParams(entry);
93
+ if (params.length === 0) {
94
+ return stringLiteral(entry.path);
95
+ }
96
+ const paramNames = new Set(params);
97
+ const parts = entry.path.split('/').slice(1).flatMap(segment => {
98
+ const paramName = segment.startsWith(':') ? segment.slice(1) : undefined;
99
+ if (paramName !== undefined && paramNames.has(paramName)) {
100
+ return [stringLiteral('/'), `encodeURIComponent(params[${stringLiteral(paramName)}])`];
101
+ }
102
+ return [stringLiteral(`/${segment}`)];
103
+ });
104
+ return `[${parts.join(', ')}].join('')`;
105
+ }
106
+ function renderParameters(entry, leadingParameter) {
107
+ if (uniqueParams(entry).length === 0) {
108
+ return `(${leadingParameter ?? ''})`;
109
+ }
110
+ const expected = `ReactPageParamsById[${stringLiteral(entry.id)}]`;
111
+ const prefix = leadingParameter === undefined ? '' : `${leadingParameter}, `;
112
+ return `<Actual extends ${expected}>(${prefix}params: Actual & Record<Actual extends ${expected} ? Exclude<keyof Actual, keyof ${expected}> : never, never>)`;
113
+ }
114
+ function renderHref(entry) {
115
+ return `href: ${renderParameters(entry)}: string => ${renderHrefExpression(entry)}`;
116
+ }
117
+ function renderLink(entry) {
118
+ return `link: ${renderParameters(entry)}: ReactPageLinkProps => ({ href: ${renderHrefExpression(entry)} })`;
119
+ }
120
+ function renderNavigation(entry, operation) {
121
+ const parameters = renderParameters(entry, 'navigator: ReactPageNavigator');
122
+ return `${operation}: ${parameters}: void => navigator.${operation}(${renderHrefExpression(entry)})`;
123
+ }
124
+ function renderRoutes(catalog) {
125
+ const lines = ['export const reactPageRoutes = {'];
126
+ for (const entry of catalog) {
127
+ lines.push(` ${stringLiteral(entry.id)}: {`);
128
+ lines.push(` id: ${stringLiteral(entry.id)},`);
129
+ lines.push(` path: ${stringLiteral(entry.path)},`);
130
+ lines.push(` ${renderHref(entry)},`);
131
+ lines.push(` ${renderLink(entry)},`);
132
+ lines.push(` ${renderNavigation(entry, 'push')},`);
133
+ lines.push(` ${renderNavigation(entry, 'replace')},`);
134
+ lines.push(' },');
135
+ }
136
+ lines.push('} as const;');
137
+ return lines;
138
+ }
139
+
140
+ /**
141
+ * Generates path-only TypeScript route declarations and href builders from a React page catalog.
142
+ *
143
+ * @param catalog Bootstrap-resolved React page catalog entries.
144
+ * @returns Deterministic TypeScript source for application-owned route tooling.
145
+ */
146
+ export function generateReactPageTypes(catalog) {
147
+ for (const entry of catalog) {
148
+ if (entry.version !== undefined) {
149
+ throw new ReactPageTypegenError(entry.id);
150
+ }
151
+ }
152
+ const sortedCatalog = [...catalog].sort((left, right) => {
153
+ if (left.id < right.id) {
154
+ return -1;
155
+ }
156
+ if (left.id > right.id) {
157
+ return 1;
158
+ }
159
+ return 0;
160
+ });
161
+ return [GENERATED_BANNER, '', `export type ReactPageRouteId = ${sortedCatalog.length === 0 ? 'never' : sortedCatalog.map(entry => stringLiteral(entry.id)).join(' | ')};`, ...renderPathMap(sortedCatalog), ...renderParamsMap(sortedCatalog), 'export type ReactPagePath<RouteId extends ReactPageRouteId> = ReactPagePathById[RouteId];', 'export type ReactPageParams<RouteId extends ReactPageRouteId> = ReactPageParamsById[RouteId];', '/** Real-anchor props produced by one generated React page route. */', 'export type ReactPageLinkProps = { readonly href: string; };', '/** Minimal HTTP-first router surface consumed by generated push and replace methods. */', 'export interface ReactPageNavigator {', ' readonly push: (href: string) => void;', ' readonly replace: (href: string) => void;', '}', ...renderRoutes(sortedCatalog), 'export type ReactPageRoute = (typeof reactPageRoutes)[ReactPageRouteId];', GENERATED_FOOTER, ''].join('\n');
162
+ }
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "runtime-neutral",
9
9
  "standard-decorators"
10
10
  ],
11
- "version": "0.1.0",
11
+ "version": "0.2.0",
12
12
  "private": false,
13
13
  "license": "MIT",
14
14
  "repository": {
@@ -33,6 +33,10 @@
33
33
  "types": "./dist/experimental/rsc.d.ts",
34
34
  "import": "./dist/experimental/rsc.js"
35
35
  },
36
+ "./typegen": {
37
+ "types": "./dist/typegen.d.ts",
38
+ "import": "./dist/typegen.js"
39
+ },
36
40
  "./vite": {
37
41
  "types": "./dist/vite.d.ts",
38
42
  "import": "./dist/vite.js"
@@ -44,10 +48,10 @@
44
48
  "dist"
45
49
  ],
46
50
  "dependencies": {
47
- "@fluojs/core": "^1.1.0",
48
- "@fluojs/di": "^2.0.0",
49
- "@fluojs/http": "^2.0.1",
50
- "@fluojs/runtime": "^2.0.1"
51
+ "@fluojs/core": "^2.0.0",
52
+ "@fluojs/di": "^3.0.0",
53
+ "@fluojs/http": "^3.0.0",
54
+ "@fluojs/runtime": "^3.0.0"
51
55
  },
52
56
  "peerDependencies": {
53
57
  "react": "^18.3.0 || ^19.0.0",
@@ -56,11 +60,12 @@
56
60
  "devDependencies": {
57
61
  "@types/react": "^19.2.14",
58
62
  "@types/react-dom": "^19.2.3",
59
- "vitest": "^3.2.4"
63
+ "vitest": "^4.1.11"
60
64
  },
61
65
  "scripts": {
62
66
  "prebuild": "node ../../tooling/scripts/clean-dist.mjs",
63
67
  "build": "pnpm exec babel src --extensions .ts --ignore 'src/**/*.test.ts' --out-dir dist --config-file ../../tooling/babel/babel.config.cjs && pnpm exec tsc -p tsconfig.build.json",
68
+ "pretest": "pnpm --dir ../.. --filter '@fluojs/react...' build",
64
69
  "typecheck": "pnpm exec tsc -p tsconfig.json --noEmit",
65
70
  "test": "pnpm exec vitest run -c vitest.config.ts",
66
71
  "test:watch": "pnpm exec vitest -c vitest.config.ts"