@bleedingdev/modern-js-renderer-octane 0.0.0-stage → 3.9.3-ultramodern.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 (138) hide show
  1. package/LICENSE +21 -0
  2. package/bin/octane-tsc.mjs +4 -0
  3. package/dist/cjs/bootstrap.js +68 -0
  4. package/dist/cjs/client.js +371 -0
  5. package/dist/cjs/entry-application.js +83 -0
  6. package/dist/cjs/entry-client.js +92 -0
  7. package/dist/cjs/entry-server.js +127 -0
  8. package/dist/cjs/federation-context.js +72 -0
  9. package/dist/cjs/federation.js +103 -0
  10. package/dist/cjs/i18n/I18nProvider.js +67 -0
  11. package/dist/cjs/i18n/LocalizedLink.js +89 -0
  12. package/dist/cjs/i18n/context.js +41 -0
  13. package/dist/cjs/i18n/index.js +86 -0
  14. package/dist/cjs/i18n/languageSync.js +99 -0
  15. package/dist/cjs/i18n/types.js +18 -0
  16. package/dist/cjs/i18n/useI18n.js +110 -0
  17. package/dist/cjs/index.js +46 -0
  18. package/dist/cjs/manifest.js +167 -0
  19. package/dist/cjs/plugin/compiler.js +125 -0
  20. package/dist/cjs/plugin/create.js +265 -0
  21. package/dist/cjs/plugin/index.js +151 -0
  22. package/dist/cjs/plugin/profile.js +89 -0
  23. package/dist/cjs/plugin/source.js +56 -0
  24. package/dist/cjs/router-client.js +139 -0
  25. package/dist/cjs/router-handler.js +168 -0
  26. package/dist/cjs/router-injection.js +139 -0
  27. package/dist/cjs/router-server-cleanup.js +46 -0
  28. package/dist/cjs/router-server-snapshot.js +405 -0
  29. package/dist/cjs/router-server.js +108 -0
  30. package/dist/cjs/router-snapshot.js +270 -0
  31. package/dist/cjs/router.js +218 -0
  32. package/dist/cjs/routes.js +187 -0
  33. package/dist/cjs/server.js +224 -0
  34. package/dist/cjs/typecheck.js +615 -0
  35. package/dist/esm/bootstrap.mjs +24 -0
  36. package/dist/esm/client.mjs +327 -0
  37. package/dist/esm/entry-application.mjs +39 -0
  38. package/dist/esm/entry-client.mjs +54 -0
  39. package/dist/esm/entry-server.mjs +86 -0
  40. package/dist/esm/federation-context.mjs +25 -0
  41. package/dist/esm/federation.mjs +65 -0
  42. package/dist/esm/i18n/I18nProvider.mjs +29 -0
  43. package/dist/esm/i18n/LocalizedLink.mjs +48 -0
  44. package/dist/esm/i18n/context.mjs +3 -0
  45. package/dist/esm/i18n/index.mjs +8 -0
  46. package/dist/esm/i18n/languageSync.mjs +61 -0
  47. package/dist/esm/i18n/types.mjs +0 -0
  48. package/dist/esm/i18n/useI18n.mjs +72 -0
  49. package/dist/esm/index.mjs +8 -0
  50. package/dist/esm/manifest.mjs +117 -0
  51. package/dist/esm/router-client.mjs +98 -0
  52. package/dist/esm/router-handler.mjs +130 -0
  53. package/dist/esm/router-injection.mjs +101 -0
  54. package/dist/esm/router-server-cleanup.mjs +8 -0
  55. package/dist/esm/router-server-snapshot.mjs +367 -0
  56. package/dist/esm/router-server.mjs +12 -0
  57. package/dist/esm/router-snapshot.mjs +199 -0
  58. package/dist/esm/router.mjs +94 -0
  59. package/dist/esm/routes.mjs +134 -0
  60. package/dist/esm/server.mjs +183 -0
  61. package/dist/esm-node/bootstrap.mjs +24 -0
  62. package/dist/esm-node/manifest.mjs +117 -0
  63. package/dist/esm-node/plugin/compiler.mjs +70 -0
  64. package/dist/esm-node/plugin/create.mjs +227 -0
  65. package/dist/esm-node/plugin/index.mjs +103 -0
  66. package/dist/esm-node/plugin/profile.mjs +51 -0
  67. package/dist/esm-node/plugin/source.mjs +7 -0
  68. package/dist/esm-node/typecheck.mjs +560 -0
  69. package/dist/types/bootstrap.d.ts +10 -0
  70. package/dist/types/client.d.ts +36 -0
  71. package/dist/types/entry-application.d.ts +19 -0
  72. package/dist/types/entry-client.d.ts +8 -0
  73. package/dist/types/entry-server.d.ts +11 -0
  74. package/dist/types/federation-context.d.ts +28 -0
  75. package/dist/types/federation.d.ts +18 -0
  76. package/dist/types/i18n/I18nProvider.d.ts +8 -0
  77. package/dist/types/i18n/LocalizedLink.d.ts +41 -0
  78. package/dist/types/i18n/context.d.ts +9 -0
  79. package/dist/types/i18n/index.d.ts +9 -0
  80. package/dist/types/i18n/languageSync.d.ts +26 -0
  81. package/dist/types/i18n/types.d.ts +34 -0
  82. package/dist/types/i18n/useI18n.d.ts +21 -0
  83. package/dist/types/index.d.ts +9 -0
  84. package/dist/types/manifest.d.ts +34 -0
  85. package/dist/types/plugin/compiler-manifest.d.cts +10 -0
  86. package/dist/types/plugin/compiler.d.ts +5 -0
  87. package/dist/types/plugin/create.d.ts +23 -0
  88. package/dist/types/plugin/index.d.ts +3 -0
  89. package/dist/types/plugin/profile.d.ts +2 -0
  90. package/dist/types/plugin/source.d.ts +1 -0
  91. package/dist/types/plugin/svg-component-template.d.cts +6 -0
  92. package/dist/types/router-client.d.ts +12 -0
  93. package/dist/types/router-handler.d.ts +13 -0
  94. package/dist/types/router-injection.d.ts +8 -0
  95. package/dist/types/router-server-cleanup.d.ts +3 -0
  96. package/dist/types/router-server-snapshot.d.ts +15 -0
  97. package/dist/types/router-server.d.ts +13 -0
  98. package/dist/types/router-snapshot.d.ts +14 -0
  99. package/dist/types/router.d.ts +30 -0
  100. package/dist/types/routes.d.ts +18 -0
  101. package/dist/types/server.d.ts +41 -0
  102. package/dist/types/typecheck.d.ts +31 -0
  103. package/package.json +216 -4
  104. package/src/bootstrap.ts +60 -0
  105. package/src/client.ts +485 -0
  106. package/src/entry-application.ts +72 -0
  107. package/src/entry-client.ts +80 -0
  108. package/src/entry-server.ts +142 -0
  109. package/src/federation-context.ts +53 -0
  110. package/src/federation.ts +158 -0
  111. package/src/i18n/I18nProvider.ts +56 -0
  112. package/src/i18n/LocalizedLink.ts +112 -0
  113. package/src/i18n/context.ts +12 -0
  114. package/src/i18n/index.ts +35 -0
  115. package/src/i18n/languageSync.ts +132 -0
  116. package/src/i18n/types.ts +36 -0
  117. package/src/i18n/useI18n.ts +119 -0
  118. package/src/index.ts +10 -0
  119. package/src/manifest.ts +253 -0
  120. package/src/plugin/compiler-manifest.cjs +178 -0
  121. package/src/plugin/compiler.ts +112 -0
  122. package/src/plugin/create.ts +272 -0
  123. package/src/plugin/index.ts +105 -0
  124. package/src/plugin/profile.ts +46 -0
  125. package/src/plugin/source.ts +16 -0
  126. package/src/plugin/svg-component-template.cjs +17 -0
  127. package/src/router-client.ts +144 -0
  128. package/src/router-handler.ts +182 -0
  129. package/src/router-injection.ts +145 -0
  130. package/src/router-server-cleanup.ts +11 -0
  131. package/src/router-server-snapshot.ts +564 -0
  132. package/src/router-server.ts +23 -0
  133. package/src/router-snapshot.ts +251 -0
  134. package/src/router.ts +192 -0
  135. package/src/routes.ts +263 -0
  136. package/src/server.ts +300 -0
  137. package/src/typecheck.ts +723 -0
  138. package/README.md +0 -3
@@ -0,0 +1,251 @@
1
+ import {
2
+ assertPublicData,
3
+ DataProtocolError,
4
+ } from '@modern-js/renderer-core/data';
5
+ import {
6
+ type AnySerializationAdapter,
7
+ createSerializationAdapter,
8
+ } from '@octanejs/tanstack-router';
9
+ import { RouteDataError } from './routes';
10
+
11
+ export const ADAPTER_KEY = 'ultramodern.octane-public-snapshot.v1';
12
+ export const ROUTE_ERROR_KIND = 'ultramodern-route-error';
13
+ export const NATIVE_ERROR_KIND = 'ultramodern-native-error';
14
+ const nativeErrorTypes: Readonly<Record<string, typeof Error>> = Object.freeze({
15
+ Error,
16
+ TypeError,
17
+ RangeError,
18
+ SyntaxError,
19
+ ReferenceError,
20
+ URIError,
21
+ EvalError,
22
+ });
23
+ export const NATIVE_ROOT_FIELDS = new Set([
24
+ 'manifest',
25
+ 'matches',
26
+ 'lastMatchId',
27
+ 'dehydratedData',
28
+ ]);
29
+ export const MATCH_FIELDS = new Set(['i', 'u', 's', 'b', 'l', 'e', 'ssr', 'g']);
30
+ export function invalid(message: string): DataProtocolError {
31
+ const error = new DataProtocolError(`Octane router snapshot: ${message}`);
32
+ // Native Seroval may continue parsing this owned diagnostic after cleanup.
33
+ // Its Error serializer must not request even a lazy native stack accessor.
34
+ delete error.stack;
35
+ return error;
36
+ }
37
+
38
+ export function descriptors(
39
+ value: object,
40
+ allowed?: ReadonlySet<string>,
41
+ allowLazyStack = false,
42
+ ): Record<string, PropertyDescriptor> {
43
+ const result: Record<string, PropertyDescriptor> = Object.create(null);
44
+ for (const key of Reflect.ownKeys(value)) {
45
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
46
+ if (typeof key !== 'string' || (allowed && !allowed.has(key))) {
47
+ throw invalid('an unsupported own field cannot be transferred');
48
+ }
49
+ if (descriptor.get || descriptor.set) {
50
+ if (key === 'stack' && allowLazyStack) continue;
51
+ throw invalid('accessors cannot be transferred');
52
+ }
53
+ result[key] = descriptor;
54
+ }
55
+ return result;
56
+ }
57
+
58
+ export function record(value: unknown): value is Record<string, unknown> {
59
+ return (
60
+ value !== null &&
61
+ typeof value === 'object' &&
62
+ (Object.getPrototypeOf(value) === Object.prototype ||
63
+ Object.getPrototypeOf(value) === null)
64
+ );
65
+ }
66
+
67
+ export function stringField(
68
+ fields: Record<string, PropertyDescriptor>,
69
+ key: string,
70
+ ) {
71
+ const value: unknown = fields[key]?.value;
72
+ if (typeof value !== 'string') throw invalid(`${key} must be a string`);
73
+ return value;
74
+ }
75
+
76
+ export function nativePromise(value: unknown): value is Promise<unknown> {
77
+ return (
78
+ value !== null &&
79
+ typeof value === 'object' &&
80
+ Object.getPrototypeOf(value) === Promise.prototype
81
+ );
82
+ }
83
+
84
+ export function clearStack(error: Error, stack: unknown) {
85
+ delete error.stack;
86
+ if (typeof stack === 'string') {
87
+ Object.defineProperty(error, 'stack', {
88
+ value: stack,
89
+ writable: true,
90
+ configurable: true,
91
+ });
92
+ }
93
+ }
94
+
95
+ function decodeRouteError(error: unknown): unknown {
96
+ if (!record(error)) return error;
97
+ const errorFields = descriptors(error);
98
+ const kind = errorFields['kind']?.value;
99
+ if (kind !== ROUTE_ERROR_KIND && kind !== NATIVE_ERROR_KIND) return error;
100
+ if (kind === NATIVE_ERROR_KIND) {
101
+ descriptors(
102
+ error,
103
+ new Set(['kind', 'type', 'name', 'message', 'stack', 'cause']),
104
+ );
105
+ assertPublicData(error);
106
+ const type = stringField(errorFields, 'type');
107
+ const Constructor = Object.getOwnPropertyDescriptor(nativeErrorTypes, type)
108
+ ?.value as typeof Error | undefined;
109
+ if (!Constructor) throw invalid('the native Error type is unsupported');
110
+ const restored = new Constructor(stringField(errorFields, 'message'));
111
+ clearStack(restored, errorFields['stack']?.value);
112
+ Object.defineProperty(restored, 'name', {
113
+ value: stringField(errorFields, 'name'),
114
+ writable: true,
115
+ configurable: true,
116
+ });
117
+ if (errorFields['cause'])
118
+ Object.defineProperty(restored, 'cause', {
119
+ value: errorFields['cause'].value,
120
+ writable: true,
121
+ configurable: true,
122
+ });
123
+ return restored;
124
+ }
125
+ const expected = new Set([
126
+ 'kind',
127
+ 'routeId',
128
+ 'status',
129
+ 'data',
130
+ 'name',
131
+ 'message',
132
+ 'stack',
133
+ 'cause',
134
+ ]);
135
+ descriptors(error, expected);
136
+ const routeId = stringField(errorFields, 'routeId');
137
+ const name = stringField(errorFields, 'name');
138
+ const message = stringField(errorFields, 'message');
139
+ const status: unknown = errorFields['status']?.value;
140
+ if (
141
+ !routeId ||
142
+ !Number.isInteger(status) ||
143
+ (status as number) < 400 ||
144
+ (status as number) > 599
145
+ ) {
146
+ throw invalid('a route error has invalid identity or status');
147
+ }
148
+ assertPublicData(error);
149
+ const restored = new RouteDataError(routeId, {
150
+ kind: 'error',
151
+ status: status as number,
152
+ error: { name, message },
153
+ data: errorFields['data']?.value,
154
+ thrown: true,
155
+ });
156
+ clearStack(restored, errorFields['stack']?.value);
157
+ if (errorFields['cause']) {
158
+ Object.defineProperty(restored, 'cause', {
159
+ value: errorFields['cause'].value,
160
+ writable: true,
161
+ configurable: true,
162
+ });
163
+ }
164
+ return restored;
165
+ }
166
+
167
+ /** Native hydrate installs this decoder before replaying adapter-dependent scripts. */
168
+ function decodeNativeSnapshot(value: unknown): object {
169
+ if (!record(value)) throw invalid('the native root must be a record');
170
+ const fields = descriptors(value, NATIVE_ROOT_FIELDS);
171
+ const matches: unknown = fields['matches']?.value;
172
+ if (!Array.isArray(matches)) throw invalid('native matches must be an array');
173
+ const matchFields = descriptors(matches);
174
+ const promises = new WeakMap<Promise<unknown>, Promise<unknown>>();
175
+ const loaders = new WeakMap<object, object>();
176
+ const errors = new WeakMap<object, unknown>();
177
+ const decodeError = (error: unknown) => {
178
+ if (error === null || typeof error !== 'object') return error;
179
+ if (errors.has(error)) return errors.get(error);
180
+ const restored = decodeRouteError(error);
181
+ errors.set(error, restored);
182
+ return restored;
183
+ };
184
+ const decodePromise = (promise: Promise<unknown>) => {
185
+ const previous = promises.get(promise);
186
+ if (previous) return previous;
187
+ const restored = Promise.prototype.then.call(
188
+ promise,
189
+ undefined,
190
+ (error: unknown) => {
191
+ throw decodeError(error);
192
+ },
193
+ ) as Promise<unknown>;
194
+ promises.set(promise, restored);
195
+ void Promise.prototype.then.call(restored, undefined, () => {});
196
+ return restored;
197
+ };
198
+ const decodeLoader = (loader: unknown): unknown => {
199
+ if (nativePromise(loader)) return decodePromise(loader);
200
+ if (!record(loader)) return loader;
201
+ const properties = descriptors(loader);
202
+ if (!Object.values(properties).some(field => nativePromise(field.value)))
203
+ return loader;
204
+ const previous = loaders.get(loader);
205
+ if (previous) return previous;
206
+ const restored = Object.create(Object.getPrototypeOf(loader));
207
+ loaders.set(loader, restored);
208
+ for (const [key, field] of Object.entries(properties)) {
209
+ Object.defineProperty(restored, key, {
210
+ ...field,
211
+ value: nativePromise(field.value)
212
+ ? decodePromise(field.value)
213
+ : field.value,
214
+ });
215
+ }
216
+ if (!Object.isExtensible(loader)) Object.preventExtensions(restored);
217
+ return restored;
218
+ };
219
+ const restoredMatches: Record<string, unknown>[] = [];
220
+ for (let index = 0; index < matches.length; index++) {
221
+ const match: unknown = matchFields[String(index)]?.value;
222
+ if (!record(match)) throw invalid('a native match must be a record');
223
+ const properties = descriptors(match, MATCH_FIELDS);
224
+ const restored: Record<string, unknown> = {};
225
+ for (const [key, field] of Object.entries(properties)) {
226
+ restored[key] =
227
+ key === 'e'
228
+ ? decodeError(field.value)
229
+ : key === 'l'
230
+ ? decodeLoader(field.value)
231
+ : field.value;
232
+ }
233
+ restoredMatches.push(restored);
234
+ }
235
+ const restored: Record<string, unknown> = {};
236
+ for (const [key, field] of Object.entries(fields))
237
+ restored[key] = key === 'matches' ? restoredMatches : field.value;
238
+ return restored;
239
+ }
240
+
241
+ // The native generic represents arbitrary dynamic match values as `any` too;
242
+ // the actual supported data contract is checked before this adapter returns.
243
+ export const octaneRouterSnapshotAdapter: AnySerializationAdapter =
244
+ createSerializationAdapter<any, any>({
245
+ key: ADAPTER_KEY,
246
+ test: (_value): _value is any => false,
247
+ toSerializable() {
248
+ throw invalid('the client decoder cannot serialize a server snapshot');
249
+ },
250
+ fromSerializable: decodeNativeSnapshot,
251
+ });
package/src/router.ts ADDED
@@ -0,0 +1,192 @@
1
+ import {
2
+ createDataClient,
3
+ type PublicDataOutcome,
4
+ } from '@modern-js/renderer-core/data';
5
+ import type { RendererIdentity } from '@modern-js/renderer-core/identity';
6
+ import {
7
+ type AnyRouter,
8
+ redirect,
9
+ useMatch,
10
+ useRouter,
11
+ } from '@octanejs/tanstack-router';
12
+ import { createSubSlot, useCallback, useEffect, useRef } from 'octane';
13
+ import { assertOctaneIdentity } from './bootstrap';
14
+ import { resolveRouteData } from './routes';
15
+
16
+ /** These bindings own matching, preloading, navigation and route component lifetimes. */
17
+ export * from '@octanejs/tanstack-router';
18
+ export {
19
+ createRouter as createApplicationRouter,
20
+ RouterProvider as ApplicationRouter,
21
+ } from '@octanejs/tanstack-router';
22
+ export type {
23
+ FileSystemDataModule,
24
+ FileSystemRouteModule,
25
+ FileSystemRouteOptions,
26
+ } from './routes';
27
+ export {
28
+ createFileSystemRouteTree,
29
+ matchApplicationRouteIds,
30
+ matchApplicationRoutes,
31
+ RouteDataError,
32
+ resolveRouteData,
33
+ selectApplicationDataRoute,
34
+ } from './routes';
35
+
36
+ /** Read the identity selected by this application's framework composition. */
37
+ export function useApplicationIdentity(): Readonly<RendererIdentity> {
38
+ const identity = useRouter().options.context?.ultramodern?.rendererIdentity;
39
+ if (!identity || typeof identity !== 'object' || Array.isArray(identity)) {
40
+ throw new Error(
41
+ 'The Octane application router is missing its renderer identity',
42
+ );
43
+ }
44
+ assertOctaneIdentity(identity);
45
+ return Object.freeze({ ...identity });
46
+ }
47
+
48
+ /** Bind an action to its nearest native route, including ancestor layouts. */
49
+ export function useApplicationRouteId(): string {
50
+ const router = useRouter();
51
+ const routeId = useMatch({ strict: false, select: match => match.routeId });
52
+ if (typeof routeId !== 'string') {
53
+ throw new Error(
54
+ 'The Octane application requires an active native route match',
55
+ );
56
+ }
57
+ const id = router.routesById[routeId]?.options.staticData?.ultramodernRouteId;
58
+ if (typeof id !== 'string' || id.length === 0) {
59
+ throw new Error(
60
+ 'The Octane application route is missing its filesystem identity',
61
+ );
62
+ }
63
+ return id;
64
+ }
65
+
66
+ /**
67
+ * Native actions follow a redirect as a navigation, a GET. HTTP lets that
68
+ * happen for 303, and for 301/302 only after POST; any other redirect keeps
69
+ * the method and body, which a navigation would silently drop, so it fails.
70
+ */
71
+ function assertNavigableActionRedirect(status: number, method: string): void {
72
+ if (
73
+ status === 307 ||
74
+ status === 308 ||
75
+ ((status === 301 || status === 302) && method !== 'POST')
76
+ )
77
+ throw new Error(
78
+ `A native action redirect must not preserve the method (HTTP ${status} after ${method}); redirect with 303 to navigate after the mutation`,
79
+ );
80
+ }
81
+
82
+ export interface OctaneRouteActionOptions {
83
+ readonly router: AnyRouter;
84
+ readonly routeId: string;
85
+ readonly identity: RendererIdentity;
86
+ readonly url?: string | (() => string);
87
+ readonly method?: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
88
+ readonly fetch?: typeof globalThis.fetch;
89
+ readonly signal?: AbortSignal;
90
+ }
91
+
92
+ /** Native useActionState owns submission state and the HTML form action. */
93
+ export function createOctaneRouteAction(input: OctaneRouteActionOptions) {
94
+ const data = createDataClient(input.routeId, input.identity, {
95
+ fetch: input.fetch,
96
+ });
97
+ return async (
98
+ _previous: PublicDataOutcome | undefined,
99
+ formData: FormData,
100
+ ): Promise<PublicDataOutcome> => {
101
+ input.signal?.throwIfAborted();
102
+ const target =
103
+ typeof input.url === 'function'
104
+ ? input.url()
105
+ : (input.url ?? input.router.latestLocation.publicHref);
106
+ const origin = input.router.options.origin ?? globalThis.location?.origin;
107
+ const request = new Request(new URL(target, origin), {
108
+ method: input.method ?? 'POST',
109
+ body: formData,
110
+ signal: input.signal,
111
+ });
112
+ const outcome = await data.action({ request });
113
+ await outcome.completion;
114
+ input.signal?.throwIfAborted();
115
+ if (outcome.kind === 'redirect') {
116
+ assertNavigableActionRedirect(outcome.status, request.method);
117
+ const resolved = input.router.resolveRedirect(
118
+ redirect({
119
+ href: new URL(outcome.location, request.url).href,
120
+ statusCode: outcome.status,
121
+ }),
122
+ );
123
+ // Native navigation chooses document loading from the normalized href.
124
+ await input.router.navigate({ href: resolved.options.href });
125
+ } else if (
126
+ (outcome.kind === 'error' || outcome.kind === 'not-found') &&
127
+ outcome.thrown
128
+ ) {
129
+ resolveRouteData(input.routeId, outcome);
130
+ } else if (outcome.kind === 'success') {
131
+ await input.router.invalidate();
132
+ }
133
+ return outcome;
134
+ };
135
+ }
136
+
137
+ export type OctaneRouteActionBinding = Pick<
138
+ OctaneRouteActionOptions,
139
+ 'url' | 'method' | 'fetch'
140
+ >;
141
+
142
+ // Plain TypeScript is not rewritten by the Octane compiler: derive each
143
+ // composed hook's slot from the caller's, with a stable slotless fallback.
144
+ const actionSlot = createSubSlot({ slotlessPrefix: 'ultramodern-action:' });
145
+
146
+ /**
147
+ * Bind an action to the nearest native route and to this component's
148
+ * lifetime: unmounting aborts a submission still in flight, so its late
149
+ * result cannot redirect or invalidate the route the user moved on to.
150
+ */
151
+ export function useOctaneRouteAction(
152
+ binding?: OctaneRouteActionBinding,
153
+ ): ReturnType<typeof createOctaneRouteAction>;
154
+ export function useOctaneRouteAction(...args: unknown[]) {
155
+ const tail = args.at(-1);
156
+ const slot = typeof tail === 'symbol' ? tail : undefined;
157
+ const binding = (
158
+ typeof args[0] === 'object' && args[0] !== null ? args[0] : {}
159
+ ) as OctaneRouteActionBinding;
160
+ const router = useRouter();
161
+ const identity = useApplicationIdentity();
162
+ const routeId = useApplicationRouteId();
163
+ const owner = useRef<AbortController | null>(null, actionSlot(slot, 'owner'));
164
+ useEffect(
165
+ () => {
166
+ const controller = new AbortController();
167
+ owner.current = controller;
168
+ return () =>
169
+ controller.abort(
170
+ new DOMException('The route action owner unmounted', 'AbortError'),
171
+ );
172
+ },
173
+ [],
174
+ actionSlot(slot, 'lifetime'),
175
+ );
176
+ const { url, method, fetch } = binding;
177
+ return useCallback(
178
+ (previous: PublicDataOutcome | undefined, formData: FormData) =>
179
+ createOctaneRouteAction({
180
+ router,
181
+ routeId,
182
+ identity,
183
+ url,
184
+ method,
185
+ fetch,
186
+ signal: owner.current?.signal,
187
+ })(previous, formData),
188
+ // The identity is read from the router, so the router stands for it.
189
+ [router, routeId, url, method, fetch],
190
+ actionSlot(slot, 'action'),
191
+ );
192
+ }
package/src/routes.ts ADDED
@@ -0,0 +1,263 @@
1
+ import type {
2
+ DataOutcome,
3
+ FileSystemRouteIR,
4
+ PublicDataOutcome,
5
+ } from '@modern-js/renderer-core/data';
6
+ import {
7
+ type FileSystemRouteOptions,
8
+ loadFileSystemRoute,
9
+ type NativeLoaderMatch,
10
+ nativeRoutePath,
11
+ resolveRouteData as resolveNativeRouteData,
12
+ splitFileSystemRoutes,
13
+ } from '@modern-js/renderer-core/router';
14
+ import {
15
+ type AnyRoute,
16
+ type AnyRouteMatch,
17
+ type AnyRouter,
18
+ createRootRoute,
19
+ createRoute,
20
+ ErrorComponent,
21
+ type ErrorRouteComponent,
22
+ HeadContent,
23
+ type NotFoundRouteComponent,
24
+ notFound,
25
+ Outlet,
26
+ type RouteComponent,
27
+ redirect,
28
+ Scripts,
29
+ } from '@octanejs/tanstack-router';
30
+ import { createElement, Fragment } from 'octane';
31
+
32
+ export type {
33
+ FileSystemDataModule,
34
+ FileSystemRouteOptions,
35
+ } from '@modern-js/renderer-core/router';
36
+ export {
37
+ matchApplicationRouteIds,
38
+ matchApplicationRoutes,
39
+ RouteDataError,
40
+ selectApplicationDataRoute,
41
+ } from '@modern-js/renderer-core/router';
42
+
43
+ /** Generated imports contain native view bindings, keyed by the structural id. */
44
+ export interface FileSystemRouteModule {
45
+ component?: RouteComponent;
46
+ pendingComponent?: RouteComponent;
47
+ errorComponent?: ErrorRouteComponent;
48
+ notFoundComponent?: NotFoundRouteComponent;
49
+ validateSearch?: (search: Record<string, unknown>) => Record<string, unknown>;
50
+ head?: AnyRoute['options']['head'];
51
+ }
52
+
53
+ /** Native route boundaries decide how redirects, missing data and errors render. */
54
+ export function resolveRouteData(
55
+ routeId: string,
56
+ outcome: DataOutcome | PublicDataOutcome,
57
+ ): unknown {
58
+ return resolveNativeRouteData({ redirect, notFound }, routeId, outcome);
59
+ }
60
+
61
+ interface ReactiveNativeMatch {
62
+ get(): AnyRouteMatch;
63
+ subscribe(callback: () => void): { unsubscribe(): void };
64
+ }
65
+
66
+ function isReactiveNativeMatch(value: unknown): value is ReactiveNativeMatch {
67
+ return (
68
+ typeof value === 'object' &&
69
+ value !== null &&
70
+ typeof Reflect.get(value, 'get') === 'function' &&
71
+ typeof Reflect.get(value, 'subscribe') === 'function'
72
+ );
73
+ }
74
+
75
+ /** A transport failure belongs to the native generation that fetched it. */
76
+ function observeRouteCompletion(
77
+ router: AnyRouter,
78
+ controller: AbortController,
79
+ signal: AbortSignal,
80
+ completion: Promise<void>,
81
+ ) {
82
+ void completion.catch(error => {
83
+ if (signal.aborted) return;
84
+ const match = [
85
+ ...router.stores.pendingMatches.get(),
86
+ ...router.stores.matches.get(),
87
+ ...router.stores.cachedMatches.get(),
88
+ ].find(candidate => candidate.abortController === controller);
89
+ if (!match) return;
90
+ const store = [
91
+ router.stores.pendingMatchStores.get(match.id),
92
+ router.stores.matchStores.get(match.id),
93
+ router.stores.cachedMatchStores.get(match.id),
94
+ ].find(candidate => candidate?.get().abortController === controller);
95
+ if (!store) return;
96
+ let subscription: { unsubscribe(): void } | undefined;
97
+ let settled = false;
98
+ const cleanup = () => {
99
+ subscription?.unsubscribe();
100
+ signal.removeEventListener('abort', onAbort);
101
+ };
102
+ const onAbort = () => {
103
+ settled = true;
104
+ cleanup();
105
+ };
106
+ const publish = () => {
107
+ if (settled) return;
108
+ const current = store.get();
109
+ if (
110
+ current.abortController !== controller ||
111
+ signal.aborted ||
112
+ (current.status !== 'pending' && current.status !== 'success')
113
+ ) {
114
+ onAbort();
115
+ return;
116
+ }
117
+ // The initial loader can still be pending after its critical value returns.
118
+ if (current.status !== 'success' || current.isFetching) return;
119
+ settled = true;
120
+ cleanup();
121
+ const route = router.routesById[current.routeId];
122
+ let failure = error;
123
+ try {
124
+ route?.options.onError?.(failure);
125
+ } catch (onErrorFailure) {
126
+ failure = onErrorFailure;
127
+ }
128
+ // Native successful loads have already preloaded their route components.
129
+ if (signal.aborted) return;
130
+ router.updateMatch(match.id, previous =>
131
+ previous.abortController === controller &&
132
+ previous.status === 'success' &&
133
+ !previous.isFetching
134
+ ? {
135
+ ...previous,
136
+ error: failure,
137
+ status: 'error',
138
+ isFetching: false,
139
+ }
140
+ : previous,
141
+ );
142
+ };
143
+ signal.addEventListener('abort', onAbort, { once: true });
144
+ if (isReactiveNativeMatch(store)) subscription = store.subscribe(publish);
145
+ if (settled) subscription?.unsubscribe();
146
+ else publish();
147
+ });
148
+ }
149
+
150
+ /** Pass filesystem structure to native factories without matching any URL. */
151
+ export function createFileSystemRouteTree<Context = unknown>(
152
+ routes: readonly FileSystemRouteIR[],
153
+ modules: Readonly<Record<string, FileSystemRouteModule>>,
154
+ options: FileSystemRouteOptions<Context, AnyRouter> = {},
155
+ ): AnyRoute {
156
+ const { root: rootDescriptor, children } = splitFileSystemRoutes(routes);
157
+
158
+ function bindings(
159
+ route: FileSystemRouteIR,
160
+ errorComponent?: ErrorRouteComponent,
161
+ ) {
162
+ const needsData = Boolean(route.modules?.data || route.modules?.clientData);
163
+ if (needsData && !options.loadRoute) {
164
+ throw new Error(`An Octane data route requires its loader: ${route.id}`);
165
+ }
166
+ const loadRoute = options.loadRoute;
167
+ const { head, ...module } = modules[route.id] ?? {};
168
+ return {
169
+ ...module,
170
+ ...(head !== undefined ? { head } : {}),
171
+ // Octane's server renders an uncaught match error through the root
172
+ // outlet's Suspense, which defers it to a client retry that then
173
+ // replaces the whole layout. Give every child match the nearest
174
+ // error.tsx (or the native default) so the error renders in place.
175
+ ...(errorComponent ? { errorComponent } : {}),
176
+ component: module.component ?? Outlet,
177
+ staticData: { ultramodernRouteId: route.id },
178
+ ...(needsData && loadRoute
179
+ ? {
180
+ loader: async (match: NativeLoaderMatch) => {
181
+ const { outcome, signal } = await loadFileSystemRoute(
182
+ route,
183
+ loadRoute,
184
+ options,
185
+ match,
186
+ );
187
+ if ('completion' in outcome && outcome.completion) {
188
+ const router = options.getRouter?.();
189
+ if (!router) {
190
+ throw new Error(
191
+ 'An Octane streamed data route requires its native router',
192
+ );
193
+ }
194
+ let failed = false;
195
+ let failure: unknown;
196
+ void outcome.completion.catch(error => {
197
+ failed = true;
198
+ failure = error;
199
+ });
200
+ // Detect a terminal failure already received with the first frame.
201
+ await Promise.resolve();
202
+ signal.throwIfAborted();
203
+ if (failed) throw failure;
204
+ observeRouteCompletion(
205
+ router,
206
+ match.abortController,
207
+ signal,
208
+ outcome.completion,
209
+ );
210
+ }
211
+ options.onOutcome?.(route.id, outcome);
212
+ return resolveRouteData(route.id, outcome);
213
+ },
214
+ }
215
+ : {}),
216
+ };
217
+ }
218
+
219
+ const rootBindings = rootDescriptor
220
+ ? bindings(rootDescriptor)
221
+ : { component: Outlet };
222
+ const authoredRoot: RouteComponent = rootBindings.component;
223
+ function OctaneDocumentRoot(props: Parameters<RouteComponent>[0]) {
224
+ return createElement(
225
+ Fragment,
226
+ null,
227
+ createElement(HeadContent),
228
+ createElement(authoredRoot, props),
229
+ createElement(Scripts),
230
+ );
231
+ }
232
+ if (authoredRoot.preload !== undefined) {
233
+ OctaneDocumentRoot.preload = authoredRoot.preload;
234
+ }
235
+ const rootErrorComponent =
236
+ (rootDescriptor && modules[rootDescriptor.id]?.errorComponent) ||
237
+ ErrorComponent;
238
+ const root = createRootRoute({
239
+ ...rootBindings,
240
+ component: OctaneDocumentRoot,
241
+ });
242
+
243
+ function bind(
244
+ route: FileSystemRouteIR,
245
+ parent: AnyRoute,
246
+ inheritedErrorComponent: ErrorRouteComponent,
247
+ ): AnyRoute {
248
+ const errorComponent =
249
+ modules[route.id]?.errorComponent ?? inheritedErrorComponent;
250
+ const native = createRoute({
251
+ getParentRoute: () => parent,
252
+ ...nativeRoutePath(route),
253
+ ...bindings(route, errorComponent),
254
+ });
255
+ return native.addChildren(
256
+ route.children.map(child => bind(child, native, errorComponent)),
257
+ );
258
+ }
259
+
260
+ return root.addChildren(
261
+ children.map(route => bind(route, root, rootErrorComponent)),
262
+ );
263
+ }