@timber-js/app 0.1.1 → 0.1.3

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 (143) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +11 -7
  3. package/dist/index.js.map +1 -1
  4. package/dist/plugins/dev-server.d.ts.map +1 -1
  5. package/dist/plugins/entries.d.ts.map +1 -1
  6. package/package.json +5 -4
  7. package/src/adapters/cloudflare.ts +325 -0
  8. package/src/adapters/nitro.ts +366 -0
  9. package/src/adapters/types.ts +63 -0
  10. package/src/cache/index.ts +91 -0
  11. package/src/cache/redis-handler.ts +91 -0
  12. package/src/cache/register-cached-function.ts +99 -0
  13. package/src/cache/singleflight.ts +26 -0
  14. package/src/cache/stable-stringify.ts +21 -0
  15. package/src/cache/timber-cache.ts +116 -0
  16. package/src/cli.ts +201 -0
  17. package/src/client/browser-entry.ts +663 -0
  18. package/src/client/error-boundary.tsx +209 -0
  19. package/src/client/form.tsx +200 -0
  20. package/src/client/head.ts +61 -0
  21. package/src/client/history.ts +46 -0
  22. package/src/client/index.ts +60 -0
  23. package/src/client/link-navigate-interceptor.tsx +62 -0
  24. package/src/client/link-status-provider.tsx +40 -0
  25. package/src/client/link.tsx +310 -0
  26. package/src/client/nuqs-adapter.tsx +117 -0
  27. package/src/client/router-ref.ts +25 -0
  28. package/src/client/router.ts +563 -0
  29. package/src/client/segment-cache.ts +194 -0
  30. package/src/client/segment-context.ts +57 -0
  31. package/src/client/ssr-data.ts +95 -0
  32. package/src/client/types.ts +4 -0
  33. package/src/client/unload-guard.ts +34 -0
  34. package/src/client/use-cookie.ts +122 -0
  35. package/src/client/use-link-status.ts +46 -0
  36. package/src/client/use-navigation-pending.ts +47 -0
  37. package/src/client/use-params.ts +71 -0
  38. package/src/client/use-pathname.ts +43 -0
  39. package/src/client/use-query-states.ts +133 -0
  40. package/src/client/use-router.ts +77 -0
  41. package/src/client/use-search-params.ts +74 -0
  42. package/src/client/use-selected-layout-segment.ts +110 -0
  43. package/src/content/index.ts +13 -0
  44. package/src/cookies/define-cookie.ts +137 -0
  45. package/src/cookies/index.ts +9 -0
  46. package/src/fonts/ast.ts +359 -0
  47. package/src/fonts/css.ts +68 -0
  48. package/src/fonts/fallbacks.ts +248 -0
  49. package/src/fonts/google.ts +332 -0
  50. package/src/fonts/local.ts +177 -0
  51. package/src/fonts/types.ts +88 -0
  52. package/src/index.ts +420 -0
  53. package/src/plugins/adapter-build.ts +118 -0
  54. package/src/plugins/build-manifest.ts +323 -0
  55. package/src/plugins/build-report.ts +353 -0
  56. package/src/plugins/cache-transform.ts +199 -0
  57. package/src/plugins/chunks.ts +90 -0
  58. package/src/plugins/content.ts +136 -0
  59. package/src/plugins/dev-error-overlay.ts +230 -0
  60. package/src/plugins/dev-logs.ts +280 -0
  61. package/src/plugins/dev-server.ts +391 -0
  62. package/src/plugins/dynamic-transform.ts +161 -0
  63. package/src/plugins/entries.ts +214 -0
  64. package/src/plugins/fonts.ts +581 -0
  65. package/src/plugins/mdx.ts +179 -0
  66. package/src/plugins/react-prod.ts +56 -0
  67. package/src/plugins/routing.ts +419 -0
  68. package/src/plugins/server-action-exports.ts +220 -0
  69. package/src/plugins/server-bundle.ts +113 -0
  70. package/src/plugins/shims.ts +168 -0
  71. package/src/plugins/static-build.ts +207 -0
  72. package/src/routing/codegen.ts +396 -0
  73. package/src/routing/index.ts +14 -0
  74. package/src/routing/interception.ts +173 -0
  75. package/src/routing/scanner.ts +487 -0
  76. package/src/routing/status-file-lint.ts +114 -0
  77. package/src/routing/types.ts +100 -0
  78. package/src/search-params/analyze.ts +192 -0
  79. package/src/search-params/codecs.ts +153 -0
  80. package/src/search-params/create.ts +314 -0
  81. package/src/search-params/index.ts +23 -0
  82. package/src/search-params/registry.ts +31 -0
  83. package/src/server/access-gate.tsx +142 -0
  84. package/src/server/action-client.ts +473 -0
  85. package/src/server/action-handler.ts +325 -0
  86. package/src/server/actions.ts +236 -0
  87. package/src/server/asset-headers.ts +81 -0
  88. package/src/server/body-limits.ts +102 -0
  89. package/src/server/build-manifest.ts +234 -0
  90. package/src/server/canonicalize.ts +90 -0
  91. package/src/server/client-module-map.ts +58 -0
  92. package/src/server/csrf.ts +79 -0
  93. package/src/server/deny-renderer.ts +302 -0
  94. package/src/server/dev-logger.ts +419 -0
  95. package/src/server/dev-span-processor.ts +78 -0
  96. package/src/server/dev-warnings.ts +282 -0
  97. package/src/server/early-hints-sender.ts +55 -0
  98. package/src/server/early-hints.ts +142 -0
  99. package/src/server/error-boundary-wrapper.ts +69 -0
  100. package/src/server/error-formatter.ts +184 -0
  101. package/src/server/flush.ts +182 -0
  102. package/src/server/form-data.ts +176 -0
  103. package/src/server/form-flash.ts +93 -0
  104. package/src/server/html-injectors.ts +445 -0
  105. package/src/server/index.ts +222 -0
  106. package/src/server/instrumentation.ts +136 -0
  107. package/src/server/logger.ts +145 -0
  108. package/src/server/manifest-status-resolver.ts +215 -0
  109. package/src/server/metadata-render.ts +527 -0
  110. package/src/server/metadata-routes.ts +189 -0
  111. package/src/server/metadata.ts +263 -0
  112. package/src/server/middleware-runner.ts +32 -0
  113. package/src/server/nuqs-ssr-provider.tsx +63 -0
  114. package/src/server/pipeline.ts +555 -0
  115. package/src/server/prerender.ts +139 -0
  116. package/src/server/primitives.ts +264 -0
  117. package/src/server/proxy.ts +43 -0
  118. package/src/server/request-context.ts +554 -0
  119. package/src/server/route-element-builder.ts +395 -0
  120. package/src/server/route-handler.ts +153 -0
  121. package/src/server/route-matcher.ts +316 -0
  122. package/src/server/rsc-entry/api-handler.ts +112 -0
  123. package/src/server/rsc-entry/error-renderer.ts +177 -0
  124. package/src/server/rsc-entry/helpers.ts +147 -0
  125. package/src/server/rsc-entry/index.ts +688 -0
  126. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  127. package/src/server/slot-resolver.ts +359 -0
  128. package/src/server/ssr-entry.ts +161 -0
  129. package/src/server/ssr-render.ts +200 -0
  130. package/src/server/status-code-resolver.ts +282 -0
  131. package/src/server/tracing.ts +281 -0
  132. package/src/server/tree-builder.ts +354 -0
  133. package/src/server/types.ts +150 -0
  134. package/src/shims/font-google.ts +67 -0
  135. package/src/shims/headers.ts +11 -0
  136. package/src/shims/image.ts +48 -0
  137. package/src/shims/link.ts +9 -0
  138. package/src/shims/navigation-client.ts +52 -0
  139. package/src/shims/navigation.ts +31 -0
  140. package/src/shims/server-only-noop.js +5 -0
  141. package/src/utils/directive-parser.ts +529 -0
  142. package/src/utils/format.ts +10 -0
  143. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,314 @@
1
+ /**
2
+ * createSearchParams — factory for SearchParamsDefinition<T>.
3
+ *
4
+ * Creates a typed, composable definition for a route's search parameters.
5
+ * Supports codec protocol, URL key aliasing, default-omission serialization,
6
+ * and composition via .extend() / .pick().
7
+ *
8
+ * Design doc: design/09-typescript.md §"Typed searchParams — search-params.ts"
9
+ */
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Types
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /**
16
+ * A codec that converts between URL string values and typed values.
17
+ *
18
+ * nuqs parsers (parseAsInteger, parseAsString, etc.) implement this
19
+ * interface natively — no adapter needed.
20
+ */
21
+ export interface SearchParamCodec<T> {
22
+ /** URL string → typed value. Receives undefined when the param is absent. */
23
+ parse(value: string | string[] | undefined): T;
24
+ /** Typed value → URL string. Return null to omit from URL. */
25
+ serialize(value: T): string | null;
26
+ }
27
+
28
+ /** Infer the output type of a codec. */
29
+ export type InferCodec<C> = C extends SearchParamCodec<infer T> ? T : never;
30
+
31
+ /** Map of property names to codecs. */
32
+ export type CodecMap<T extends Record<string, unknown>> = {
33
+ [K in keyof T]: SearchParamCodec<T[K]>;
34
+ };
35
+
36
+ /** Options for useQueryStates setter. */
37
+ export interface SetParamsOptions {
38
+ /** Update URL without server roundtrip (default: false). */
39
+ shallow?: boolean;
40
+ /** Scroll to top after update (default: true). */
41
+ scroll?: boolean;
42
+ /** 'push' (default) or 'replace' for history state. */
43
+ history?: 'push' | 'replace';
44
+ }
45
+
46
+ /** Setter function returned by useQueryStates. */
47
+ export type SetParams<T> = (values: Partial<T>, options?: SetParamsOptions) => void;
48
+
49
+ /** Options for useQueryStates hook. */
50
+ export interface QueryStatesOptions {
51
+ /** Update URL without server roundtrip (default: false). */
52
+ shallow?: boolean;
53
+ /** Scroll to top after update (default: true). */
54
+ scroll?: boolean;
55
+ /** 'push' (default) or 'replace' for history state. */
56
+ history?: 'push' | 'replace';
57
+ }
58
+
59
+ /** Options for createSearchParams and .extend(). */
60
+ export interface SearchParamsOptions<Keys extends string = string> {
61
+ /** Map property names to different URL query parameter keys. */
62
+ urlKeys?: Partial<Record<Keys, string>>;
63
+ }
64
+
65
+ /**
66
+ * A fully typed, composable search params definition.
67
+ *
68
+ * Returned by createSearchParams(). Carries a phantom _type property
69
+ * for build-time type extraction.
70
+ */
71
+ export interface SearchParamsDefinition<T extends Record<string, unknown>> {
72
+ /** Parse raw URL search params into typed values. */
73
+ parse(raw: URLSearchParams | Record<string, string | string[] | undefined>): T;
74
+
75
+ /** Client hook — reads current URL params and returns typed values + setter. */
76
+ useQueryStates(options?: QueryStatesOptions): [T, SetParams<T>];
77
+
78
+ /** Extend with additional codecs. Key collisions are a type error. */
79
+ extend<U extends Record<string, SearchParamCodec<unknown>>>(
80
+ codecs: U,
81
+ options?: SearchParamsOptions<string>
82
+ ): SearchParamsDefinition<T & { [K in keyof U]: InferCodec<U[K]> }>;
83
+
84
+ /** Pick a subset of keys. Preserves codecs and aliases. */
85
+ pick<K extends keyof T & string>(...keys: K[]): SearchParamsDefinition<Pick<T, K>>;
86
+
87
+ /** Serialize values to a query string (no leading '?'), omitting defaults. */
88
+ serialize(values: Partial<T>): string;
89
+
90
+ /** Build a full path with query string, omitting defaults. */
91
+ href(pathname: string, values: Partial<T>): string;
92
+
93
+ /** Build a URLSearchParams instance, omitting defaults. */
94
+ toSearchParams(values: Partial<T>): URLSearchParams;
95
+
96
+ /** Read-only codec map for spreading into .extend(). Aliases NOT carried. */
97
+ codecs: { [K in keyof T]: SearchParamCodec<T[K]> };
98
+
99
+ /** Read-only URL key alias map. Maps property names to URL query parameter keys. */
100
+ readonly urlKeys: Readonly<Record<string, string>>;
101
+
102
+ /**
103
+ * Phantom property for build-time type extraction.
104
+ * Never set at runtime — exists only in the type system.
105
+ */
106
+ readonly _type?: T;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Internal helpers
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /**
114
+ * Convert URLSearchParams or a plain record to a normalized record
115
+ * where repeated keys produce arrays.
116
+ */
117
+ function normalizeRaw(
118
+ raw: URLSearchParams | Record<string, string | string[] | undefined>
119
+ ): Record<string, string | string[] | undefined> {
120
+ if (raw instanceof URLSearchParams) {
121
+ const result: Record<string, string | string[] | undefined> = {};
122
+ for (const key of new Set(raw.keys())) {
123
+ const values = raw.getAll(key);
124
+ result[key] = values.length === 1 ? values[0] : values;
125
+ }
126
+ return result;
127
+ }
128
+ return raw;
129
+ }
130
+
131
+ /**
132
+ * Compute the serialized default value for a codec. Used for
133
+ * default-omission: when serialize(value) === serialize(parse(undefined)),
134
+ * the field is omitted from the URL.
135
+ */
136
+ function getDefaultSerialized<T>(codec: SearchParamCodec<T>): string | null {
137
+ return codec.serialize(codec.parse(undefined));
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Factory
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /**
145
+ * Create a SearchParamsDefinition from a codec map and optional URL key aliases.
146
+ *
147
+ * ```ts
148
+ * import { createSearchParams, fromSchema } from '@timber/app/search-params'
149
+ * import { z } from 'zod/v4'
150
+ *
151
+ * export default createSearchParams({
152
+ * page: fromSchema(z.coerce.number().int().min(1).default(1)),
153
+ * q: { parse: (v) => v ?? null, serialize: (v) => v },
154
+ * }, {
155
+ * urlKeys: { q: 'search' },
156
+ * })
157
+ * ```
158
+ */
159
+ export function createSearchParams<C extends Record<string, SearchParamCodec<unknown>>>(
160
+ codecs: C,
161
+ options?: SearchParamsOptions<Extract<keyof C, string>>
162
+ ): SearchParamsDefinition<{ [K in keyof C]: InferCodec<C[K]> }> {
163
+ type T = { [K in keyof C]: InferCodec<C[K]> };
164
+ const urlKeys: Record<string, string> = {};
165
+ if (options?.urlKeys) {
166
+ for (const [k, v] of Object.entries(options.urlKeys)) {
167
+ if (v !== undefined) urlKeys[k] = v;
168
+ }
169
+ }
170
+
171
+ return buildDefinition<T>(codecs as unknown as CodecMap<T>, urlKeys);
172
+ }
173
+
174
+ /**
175
+ * Internal: build a SearchParamsDefinition from a typed codec map and url keys.
176
+ */
177
+ function buildDefinition<T extends Record<string, unknown>>(
178
+ codecMap: CodecMap<T>,
179
+ urlKeys: Record<string, string>
180
+ ): SearchParamsDefinition<T> {
181
+ // Pre-compute default serialized values for omission check
182
+ const defaultSerialized: Record<string, string | null> = {};
183
+ for (const key of Object.keys(codecMap)) {
184
+ defaultSerialized[key] = getDefaultSerialized(codecMap[key as keyof T]);
185
+ }
186
+
187
+ function getUrlKey(prop: string): string {
188
+ return urlKeys[prop] ?? prop;
189
+ }
190
+
191
+ // ---- parse ----
192
+ function parse(raw: URLSearchParams | Record<string, string | string[] | undefined>): T {
193
+ const normalized = normalizeRaw(raw);
194
+ const result: Record<string, unknown> = {};
195
+
196
+ for (const prop of Object.keys(codecMap)) {
197
+ const urlKey = getUrlKey(prop);
198
+ const rawValue = normalized[urlKey];
199
+ result[prop] = (codecMap[prop as keyof T] as SearchParamCodec<unknown>).parse(rawValue);
200
+ }
201
+
202
+ return result as T;
203
+ }
204
+
205
+ // ---- serialize ----
206
+ function serialize(values: Partial<T>): string {
207
+ const parts: string[] = [];
208
+
209
+ for (const prop of Object.keys(codecMap)) {
210
+ if (!(prop in values)) continue;
211
+ const codec = codecMap[prop as keyof T] as SearchParamCodec<unknown>;
212
+ const serialized = codec.serialize(values[prop as keyof T] as unknown);
213
+
214
+ // Omit if serialized value matches the default
215
+ if (serialized === defaultSerialized[prop]) continue;
216
+ if (serialized === null) continue;
217
+
218
+ parts.push(`${encodeURIComponent(getUrlKey(prop))}=${encodeURIComponent(serialized)}`);
219
+ }
220
+
221
+ return parts.join('&');
222
+ }
223
+
224
+ // ---- href ----
225
+ function href(pathname: string, values: Partial<T>): string {
226
+ const qs = serialize(values);
227
+ return qs ? `${pathname}?${qs}` : pathname;
228
+ }
229
+
230
+ // ---- toSearchParams ----
231
+ function toSearchParams(values: Partial<T>): URLSearchParams {
232
+ const usp = new URLSearchParams();
233
+
234
+ for (const prop of Object.keys(codecMap)) {
235
+ if (!(prop in values)) continue;
236
+ const codec = codecMap[prop as keyof T] as SearchParamCodec<unknown>;
237
+ const serialized = codec.serialize(values[prop as keyof T] as unknown);
238
+
239
+ if (serialized === defaultSerialized[prop]) continue;
240
+ if (serialized === null) continue;
241
+
242
+ usp.set(getUrlKey(prop), serialized);
243
+ }
244
+
245
+ return usp;
246
+ }
247
+
248
+ // ---- extend ----
249
+ function extend<U extends Record<string, SearchParamCodec<unknown>>>(
250
+ newCodecs: U,
251
+ extendOptions?: SearchParamsOptions<string>
252
+ ): SearchParamsDefinition<T & { [K in keyof U]: InferCodec<U[K]> }> {
253
+ type Combined = T & { [K in keyof U]: InferCodec<U[K]> };
254
+
255
+ const combinedCodecs = {
256
+ ...codecMap,
257
+ ...newCodecs,
258
+ } as unknown as CodecMap<Combined>;
259
+
260
+ // Merge URL keys: extend options override, but do NOT inherit from base
261
+ // (aliases are route-level, not carried through .codecs)
262
+ const combinedUrlKeys: Record<string, string> = { ...urlKeys };
263
+ if (extendOptions?.urlKeys) {
264
+ for (const [k, v] of Object.entries(extendOptions.urlKeys)) {
265
+ if (v !== undefined) combinedUrlKeys[k] = v;
266
+ }
267
+ }
268
+
269
+ return buildDefinition<Combined>(combinedCodecs, combinedUrlKeys);
270
+ }
271
+
272
+ // ---- pick ----
273
+ function pick<K extends keyof T & string>(...keys: K[]): SearchParamsDefinition<Pick<T, K>> {
274
+ const pickedCodecs: Record<string, SearchParamCodec<unknown>> = {};
275
+ const pickedUrlKeys: Record<string, string> = {};
276
+
277
+ for (const key of keys) {
278
+ pickedCodecs[key] = codecMap[key] as SearchParamCodec<unknown>;
279
+ if (key in urlKeys) {
280
+ pickedUrlKeys[key] = urlKeys[key];
281
+ }
282
+ }
283
+
284
+ return buildDefinition<Pick<T, K>>(
285
+ pickedCodecs as unknown as CodecMap<Pick<T, K>>,
286
+ pickedUrlKeys
287
+ );
288
+ }
289
+
290
+ // ---- useQueryStates ----
291
+ // This is a placeholder that will be replaced by the client runtime.
292
+ // At import time in a server context, calling this throws.
293
+ // The actual implementation wraps nuqs and lives in @timber/app/client.
294
+ function useQueryStates(_options?: QueryStatesOptions): [T, SetParams<T>] {
295
+ throw new Error(
296
+ 'useQueryStates() can only be called in a client component. ' +
297
+ 'Import from @timber/app/client instead.'
298
+ );
299
+ }
300
+
301
+ const definition: SearchParamsDefinition<T> = {
302
+ parse,
303
+ useQueryStates,
304
+ extend,
305
+ pick,
306
+ serialize,
307
+ href,
308
+ toSearchParams,
309
+ codecs: codecMap,
310
+ urlKeys: Object.freeze({ ...urlKeys }),
311
+ };
312
+
313
+ return definition;
314
+ }
@@ -0,0 +1,23 @@
1
+ // @timber/app/search-params — Typed search params
2
+
3
+ // Core types and factory
4
+ export type {
5
+ SearchParamCodec,
6
+ InferCodec,
7
+ SearchParamsDefinition,
8
+ SetParams,
9
+ SetParamsOptions,
10
+ QueryStatesOptions,
11
+ SearchParamsOptions,
12
+ } from './create.js';
13
+ export { createSearchParams } from './create.js';
14
+
15
+ // Codec bridges
16
+ export { fromSchema, fromArraySchema } from './codecs.js';
17
+
18
+ // Runtime registry (route-scoped useQueryStates)
19
+ export { registerSearchParams, getSearchParams } from './registry.js';
20
+
21
+ // Static analysis (build-time only)
22
+ export type { AnalyzeResult, AnalyzeError } from './analyze.js';
23
+ export { analyzeSearchParams, formatAnalyzeError } from './analyze.js';
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Runtime registry for route-scoped search params definitions.
3
+ *
4
+ * When a route's modules load, the framework registers its search-params
5
+ * definition here. useQueryStates('/route') resolves codecs from this map.
6
+ *
7
+ * Design doc: design/23-search-params.md §"Runtime: Registration at Route Load"
8
+ */
9
+
10
+ import type { SearchParamsDefinition } from './create.js';
11
+
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ const registry = new Map<string, SearchParamsDefinition<any>>();
14
+
15
+ /**
16
+ * Register a route's search params definition.
17
+ * Called by the generated route manifest loader when a route's modules load.
18
+ */
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ export function registerSearchParams(route: string, definition: SearchParamsDefinition<any>): void {
21
+ registry.set(route, definition);
22
+ }
23
+
24
+ /**
25
+ * Look up a route's search params definition.
26
+ * Returns undefined if the route hasn't been loaded yet.
27
+ */
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ export function getSearchParams(route: string): SearchParamsDefinition<any> | undefined {
30
+ return registry.get(route);
31
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * AccessGate and SlotAccessGate — framework-injected async server components.
3
+ *
4
+ * AccessGate wraps each segment's layout in the element tree. It calls the
5
+ * segment's access.ts before the layout renders. If access.ts calls deny()
6
+ * or redirect(), the signal propagates as a render-phase throw — caught by
7
+ * the flush controller to produce the correct HTTP status code.
8
+ *
9
+ * SlotAccessGate wraps parallel slot content. On denial, it renders the
10
+ * graceful degradation chain: denied.tsx → default.tsx → null. Slot denial
11
+ * does not affect the HTTP status code.
12
+ *
13
+ * See design/04-authorization.md and design/02-rendering-pipeline.md §"AccessGate"
14
+ */
15
+
16
+ import { DenySignal, RedirectSignal } from './primitives.js';
17
+ import type { AccessGateProps, SlotAccessGateProps, ReactElement } from './tree-builder.js';
18
+ import { withSpan, setSpanAttribute } from './tracing.js';
19
+
20
+ // ─── AccessGate ─────────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Framework-injected access gate for segments.
24
+ *
25
+ * When a pre-computed `verdict` prop is provided (from the pre-render pass
26
+ * in route-element-builder.ts), AccessGate replays it synchronously — no
27
+ * async, no re-execution of access.ts, immune to Suspense timing. The OTEL
28
+ * span was already emitted during the pre-render pass.
29
+ *
30
+ * When no verdict is provided (backward compat with tree-builder.ts),
31
+ * AccessGate calls accessFn directly with OTEL instrumentation.
32
+ *
33
+ * access.ts is a pure gate — return values are discarded. The layout below
34
+ * gets the same data by calling the same cached functions (React.cache dedup).
35
+ */
36
+ export function AccessGate(props: AccessGateProps): ReactElement | Promise<ReactElement> {
37
+ const { accessFn, params, searchParams, segmentName, verdict, children } = props;
38
+
39
+ // Fast path: replay pre-computed verdict from the pre-render pass.
40
+ // This is synchronous — Suspense boundaries cannot interfere with the
41
+ // status code because the signal throws before any async work.
42
+ if (verdict !== undefined) {
43
+ if (verdict === 'pass') {
44
+ return children;
45
+ }
46
+ // Throw the stored DenySignal or RedirectSignal synchronously.
47
+ // React catches this as a render-phase throw — the flush controller
48
+ // produces the correct HTTP status code.
49
+ throw verdict;
50
+ }
51
+
52
+ // Fallback: call accessFn directly (used by tree-builder.ts which
53
+ // doesn't run a pre-render pass, and for backward compat).
54
+ return accessGateFallback(accessFn, params, searchParams, segmentName, children);
55
+ }
56
+
57
+ /**
58
+ * Async fallback for AccessGate when no pre-computed verdict is available.
59
+ * Calls accessFn with OTEL instrumentation.
60
+ */
61
+ async function accessGateFallback(
62
+ accessFn: AccessGateProps['accessFn'],
63
+ params: AccessGateProps['params'],
64
+ searchParams: AccessGateProps['searchParams'],
65
+ segmentName: AccessGateProps['segmentName'],
66
+ children: ReactElement
67
+ ): Promise<ReactElement> {
68
+ await withSpan('timber.access', { 'timber.segment': segmentName ?? 'unknown' }, async () => {
69
+ try {
70
+ await accessFn({ params, searchParams });
71
+ await setSpanAttribute('timber.result', 'pass');
72
+ } catch (error: unknown) {
73
+ if (error instanceof DenySignal) {
74
+ await setSpanAttribute('timber.result', 'deny');
75
+ await setSpanAttribute('timber.deny_status', error.status);
76
+ if (error.sourceFile) {
77
+ await setSpanAttribute('timber.deny_file', error.sourceFile);
78
+ }
79
+ } else if (error instanceof RedirectSignal) {
80
+ await setSpanAttribute('timber.result', 'redirect');
81
+ }
82
+ throw error;
83
+ }
84
+ });
85
+
86
+ return children;
87
+ }
88
+
89
+ // ─── SlotAccessGate ─────────────────────────────────────────────────────────
90
+
91
+ /**
92
+ * Framework-injected access gate for parallel slots.
93
+ *
94
+ * On denial, graceful degradation: denied.tsx → default.tsx → null.
95
+ * The HTTP status code is unaffected — slot denial is a UI concern, not
96
+ * a protocol concern. The parent layout and sibling slots still render.
97
+ *
98
+ * redirect() in slot access.ts is a dev-mode error — redirecting from a
99
+ * slot doesn't make architectural sense.
100
+ */
101
+ export async function SlotAccessGate(props: SlotAccessGateProps): Promise<ReactElement> {
102
+ const { accessFn, params, searchParams, deniedFallback, defaultFallback, children } = props;
103
+
104
+ try {
105
+ await accessFn({ params, searchParams });
106
+ } catch (error: unknown) {
107
+ // DenySignal → graceful degradation (denied.tsx → default.tsx → null)
108
+ if (error instanceof DenySignal) {
109
+ return deniedFallback ?? defaultFallback ?? null;
110
+ }
111
+
112
+ // RedirectSignal in slot access → dev-mode error.
113
+ // Slot access should use deny(), not redirect(). Redirecting from a
114
+ // slot would redirect the entire page, which breaks the contract that
115
+ // slot failure is graceful degradation.
116
+ if (error instanceof RedirectSignal) {
117
+ if (process.env.NODE_ENV !== 'production') {
118
+ console.error(
119
+ '[timber] redirect() is not allowed in slot access.ts. ' +
120
+ 'Slots use deny() for graceful degradation — denied.tsx → default.tsx → null. ' +
121
+ "If you need to redirect, move the logic to the parent segment's access.ts."
122
+ );
123
+ }
124
+ // In production, treat as a deny — render fallback rather than crash.
125
+ return deniedFallback ?? defaultFallback ?? null;
126
+ }
127
+
128
+ // Unhandled error — re-throw so error boundaries can catch it.
129
+ // Dev-mode warning: slot access should use deny(), not throw.
130
+ if (process.env.NODE_ENV !== 'production') {
131
+ console.warn(
132
+ '[timber] Unhandled error in slot access.ts. ' +
133
+ 'Use deny() for access control, not unhandled throws.',
134
+ error
135
+ );
136
+ }
137
+ throw error;
138
+ }
139
+
140
+ // Access passed — render slot content.
141
+ return children;
142
+ }