@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,220 @@
1
+ import type { Plugin } from 'vite';
2
+ import { Parser } from 'acorn';
3
+ import acornJsx from 'acorn-jsx';
4
+ import { detectFileDirective } from '#/utils/directive-parser.js';
5
+
6
+ const jsxParser = Parser.extend(acornJsx());
7
+
8
+ /**
9
+ * Rewrite 'use server' module exports to bypass RSC plugin AST validation.
10
+ *
11
+ * The RSC plugin's client/SSR proxy transform (transformProxyExport) requires
12
+ * all `export const` initializers to be `async ArrowFunctionExpression`. But
13
+ * `createActionClient().action()` and `validated()` return async functions via
14
+ * CallExpressions — valid at runtime but rejected by the static AST check.
15
+ *
16
+ * This plugin rewrites non-function-expression exports from:
17
+ *
18
+ * export const foo = someCall();
19
+ *
20
+ * to:
21
+ *
22
+ * const foo = someCall();
23
+ * export { foo };
24
+ *
25
+ * The `export { name }` form bypasses the RSC plugin's validation without
26
+ * changing runtime semantics. Function expression exports (async arrows,
27
+ * async function expressions) are left untouched.
28
+ *
29
+ * See design/08-forms-and-actions.md §"Middleware for Server Actions"
30
+ */
31
+ export function timberServerActionExports(): Plugin {
32
+ return {
33
+ name: 'timber-server-action-exports',
34
+ transform(code, id) {
35
+ // Skip non-JS/TS files
36
+ if (!/\.[jt]sx?$/.test(id)) return;
37
+ // Quick bail-out
38
+ if (!code.includes('use server')) return;
39
+ // Check for file-level directive
40
+ const directive = detectFileDirective(code, ['use server']);
41
+ if (!directive) return;
42
+
43
+ return rewriteServerActionExports(code);
44
+ },
45
+ };
46
+ }
47
+
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ type AcornNode = any;
50
+
51
+ /**
52
+ * Check if an AST node is an async function expression (arrow or regular).
53
+ * These are handled correctly by the RSC plugin and don't need rewriting.
54
+ */
55
+ function isAsyncFunctionExpr(node: AcornNode): boolean {
56
+ if (!node) return false;
57
+ return (
58
+ (node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') && node.async
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Extract identifier names from a declarator's id pattern.
64
+ * Handles simple identifiers and destructuring patterns.
65
+ */
66
+ function extractDeclNames(id: AcornNode): string[] {
67
+ if (id.type === 'Identifier') return [id.name];
68
+
69
+ // ObjectPattern: const { a, b } = ...
70
+ if (id.type === 'ObjectPattern') {
71
+ return id.properties.flatMap((p: AcornNode) => extractDeclNames(p.value ?? p.argument));
72
+ }
73
+
74
+ // ArrayPattern: const [a, b] = ...
75
+ if (id.type === 'ArrayPattern') {
76
+ return id.elements.filter(Boolean).flatMap((e: AcornNode) => extractDeclNames(e));
77
+ }
78
+
79
+ // RestElement: const [...rest] = ...
80
+ if (id.type === 'RestElement') {
81
+ return extractDeclNames(id.argument);
82
+ }
83
+
84
+ // AssignmentPattern: const { a = 1 } = ...
85
+ if (id.type === 'AssignmentPattern') {
86
+ return extractDeclNames(id.left);
87
+ }
88
+
89
+ return [];
90
+ }
91
+
92
+ interface RewriteTarget {
93
+ /** Start offset of the `export` keyword */
94
+ exportStart: number;
95
+ /** Start offset of the `const/let/var` keyword (right after `export `) */
96
+ declStart: number;
97
+ /** Names to re-export */
98
+ names: string[];
99
+ }
100
+
101
+ function rewriteServerActionExports(code: string): { code: string; map: null } | undefined {
102
+ let ast: AcornNode;
103
+ try {
104
+ ast = jsxParser.parse(code, {
105
+ ecmaVersion: 'latest',
106
+ sourceType: 'module',
107
+ });
108
+ } catch {
109
+ // TypeScript that esbuild hasn't stripped yet — use regex fallback
110
+ return rewriteServerActionExportsFallback(code);
111
+ }
112
+
113
+ const targets: RewriteTarget[] = [];
114
+
115
+ for (const node of ast.body) {
116
+ // Case 1: export const/let/var name = <non-function-expression>
117
+ if (
118
+ node.type === 'ExportNamedDeclaration' &&
119
+ node.declaration?.type === 'VariableDeclaration'
120
+ ) {
121
+ const hasNonFunctionInit = node.declaration.declarations.some(
122
+ (d: AcornNode) => d.init && !isAsyncFunctionExpr(d.init)
123
+ );
124
+ if (!hasNonFunctionInit) continue;
125
+
126
+ const names = node.declaration.declarations.flatMap((d: AcornNode) => extractDeclNames(d.id));
127
+ if (names.length === 0) continue;
128
+
129
+ targets.push({
130
+ exportStart: node.start,
131
+ declStart: node.declaration.start,
132
+ names,
133
+ });
134
+ }
135
+
136
+ // Case 2: export default <non-function-expression>
137
+ if (node.type === 'ExportDefaultDeclaration') {
138
+ const decl = node.declaration;
139
+ if (
140
+ decl.type !== 'FunctionDeclaration' &&
141
+ decl.type !== 'Identifier' &&
142
+ !isAsyncFunctionExpr(decl)
143
+ ) {
144
+ // export default someCall() → const $$default = someCall(); export default $$default;
145
+ targets.push({
146
+ exportStart: node.start,
147
+ declStart: -1, // sentinel for default export
148
+ names: ['default'],
149
+ });
150
+ }
151
+ }
152
+ }
153
+
154
+ if (targets.length === 0) return undefined;
155
+
156
+ // Process from end to start to preserve character positions
157
+ let result = code;
158
+ const reExports: string[] = [];
159
+
160
+ for (let i = targets.length - 1; i >= 0; i--) {
161
+ const target = targets[i];
162
+
163
+ if (target.declStart === -1) {
164
+ // export default <expr> → const $$default = <expr>;\nexport default $$default;
165
+ const node = ast.body.find(
166
+ (n: AcornNode) => n.type === 'ExportDefaultDeclaration' && n.start === target.exportStart
167
+ );
168
+ if (node) {
169
+ const exprCode = code.slice(node.declaration.start, node.declaration.end);
170
+ const replacement = `const $$default = ${exprCode};\nexport default $$default;`;
171
+ result = result.slice(0, node.start) + replacement + result.slice(node.end);
172
+ }
173
+ continue;
174
+ }
175
+
176
+ // Remove 'export ' from the declaration
177
+ result = result.slice(0, target.exportStart) + result.slice(target.declStart);
178
+ reExports.push(...target.names);
179
+ }
180
+
181
+ if (reExports.length > 0) {
182
+ result += '\nexport { ' + reExports.join(', ') + ' };\n';
183
+ }
184
+
185
+ return { code: result, map: null };
186
+ }
187
+
188
+ /**
189
+ * Regex fallback for TypeScript files that acorn cannot parse.
190
+ *
191
+ * Matches `export const/let/var name = <expr>` where the initializer does
192
+ * NOT start with `async function` or `async (` (i.e., not an async
193
+ * function expression or async arrow function).
194
+ */
195
+ function rewriteServerActionExportsFallback(code: string): { code: string; map: null } | undefined {
196
+ // Match: export const/let/var <name> = <non-async-function-expr>
197
+ const pattern =
198
+ /^(export\s+)((?:const|let|var)\s+(\w+)\s*=\s*)(?!async\s+(?:function[\s(]|\())/gm;
199
+
200
+ const names: string[] = [];
201
+ let result = code;
202
+ let offset = 0;
203
+ let match;
204
+
205
+ while ((match = pattern.exec(code)) !== null) {
206
+ const exportKeyword = match[1]; // 'export ' (with trailing space)
207
+ const name = match[3];
208
+
209
+ // Remove 'export ' at this position (adjusted for prior removals)
210
+ const pos = match.index + offset;
211
+ result = result.slice(0, pos) + result.slice(pos + exportKeyword.length);
212
+ offset -= exportKeyword.length;
213
+ names.push(name);
214
+ }
215
+
216
+ if (names.length === 0) return undefined;
217
+
218
+ result += '\nexport { ' + names.join(', ') + ' };\n';
219
+ return { code: result, map: null };
220
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * timber-server-bundle — Bundle all dependencies for server environments.
3
+ *
4
+ * In production builds, sets `resolve.noExternal: true` for the rsc and ssr
5
+ * environments. This ensures all npm dependencies are bundled into the output,
6
+ * which is required for platforms like Cloudflare Workers that don't have
7
+ * access to node_modules at runtime.
8
+ *
9
+ * In dev mode, Vite's default externalization is preserved for fast HMR.
10
+ *
11
+ * Design docs: design/11-platform.md, design/25-production-deployments.md
12
+ */
13
+
14
+ import type { Plugin } from 'vite';
15
+
16
+ export function timberServerBundle(): Plugin[] {
17
+ const bundlePlugin: Plugin = {
18
+ name: 'timber-server-bundle',
19
+
20
+ config(_cfg, { command }) {
21
+ // In dev mode, Vite externalizes node_modules by default for fast HMR.
22
+ // But server-only/client-only must NOT be externalized — they need to
23
+ // go through the timber-shims plugin so it can replace them with no-op
24
+ // virtual modules. Without this, deps like `bright` that import
25
+ // `server-only` get the real CJS package loaded via Node's require(),
26
+ // which throws in the SSR environment.
27
+ if (command === 'serve') {
28
+ // In dev, Vite externalizes node_modules and loads them via Node's
29
+ // native require(). Deps that import `server-only` (like `bright`)
30
+ // hit the real CJS package which throws at runtime. We force these
31
+ // poison-pill packages to be non-external so they go through Vite's
32
+ // module pipeline, where the timber-shims plugin intercepts them
33
+ // and serves no-op virtual modules for server environments.
34
+ return {
35
+ environments: {
36
+ rsc: {
37
+ resolve: {
38
+ noExternal: ['server-only', 'client-only'],
39
+ },
40
+ },
41
+ ssr: {
42
+ resolve: {
43
+ noExternal: ['server-only', 'client-only'],
44
+ },
45
+ },
46
+ },
47
+ };
48
+ }
49
+
50
+ // Bundle all dependencies in server environments for production.
51
+ // Without this, bare imports like 'nuqs/adapters/custom' are left
52
+ // as external imports in the output, which fails on platforms
53
+ // without node_modules (Cloudflare Workers, edge runtimes).
54
+ // Define process.env.NODE_ENV in server environments so that
55
+ // dead-code branches (dev-only tracing, React dev checks) are
56
+ // eliminated by Rollup's tree-shaking. Without this, the runtime
57
+ // check falls through on platforms where process.env is empty
58
+ // (e.g. Cloudflare Workers), causing dev code to run in production.
59
+ const serverDefine = {
60
+ 'process.env.NODE_ENV': JSON.stringify('production'),
61
+ };
62
+
63
+ return {
64
+ // Target webworker so Rolldown doesn't emit createRequire(import.meta.url)
65
+ // shims that fail in Cloudflare Workers where import.meta.url is undefined
66
+ // for non-entry modules. See design/11-platform.md.
67
+ ssr: { target: 'webworker' },
68
+ environments: {
69
+ rsc: {
70
+ resolve: { noExternal: true },
71
+ define: serverDefine,
72
+ },
73
+ ssr: {
74
+ resolve: { noExternal: true },
75
+ define: serverDefine,
76
+ },
77
+ },
78
+ };
79
+ },
80
+ };
81
+
82
+ // Fix Rolldown's broken `__esmMin` lazy initializers in server bundles.
83
+ //
84
+ // Rolldown wraps ESM module initialization in `__esmMin` lazy functions.
85
+ // For packages with `sideEffects: false` (e.g. nuqs), Rolldown drops
86
+ // the variable assignment of the init function — so the module's React
87
+ // imports, context creation, etc. never execute.
88
+ //
89
+ // The fix: patch the `__esmMin` runtime definition to eagerly execute
90
+ // the init callback while still returning the lazy wrapper. This makes
91
+ // all ESM module inits run at load time (standard ESM behavior) instead
92
+ // of lazily, which is functionally correct and avoids the dropped-init bug.
93
+ const esmInitFixPlugin: Plugin = {
94
+ name: 'timber-esm-init-fix',
95
+ applyToEnvironment(environment) {
96
+ return environment.name === 'rsc' || environment.name === 'ssr';
97
+ },
98
+ renderChunk(code) {
99
+ const lazy = 'var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);';
100
+ if (!code.includes(lazy)) return null;
101
+
102
+ // Replace with eager-then-lazy: execute init immediately, then
103
+ // return the lazy wrapper for any subsequent calls (which are
104
+ // idempotent since fn is set to 0 after first execution).
105
+ const eager =
106
+ 'var __esmMin = (fn, res) => { var l = () => (fn && (res = fn(fn = 0)), res); l(); return l; };';
107
+
108
+ return { code: code.replace(lazy, eager), map: null };
109
+ },
110
+ };
111
+
112
+ return [bundlePlugin, esmInitFixPlugin];
113
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * timber-shims — Vite sub-plugin for next/* → timber shim resolution.
3
+ *
4
+ * Intercepts imports of next/* modules and redirects them to timber.js
5
+ * shim implementations. This enables Next.js-compatible libraries
6
+ * (nuqs, next-intl, etc.) to work unmodified.
7
+ *
8
+ * Design doc: 18-build-system.md §"Shim Map"
9
+ */
10
+
11
+ import type { Plugin } from 'vite';
12
+ import { resolve, dirname } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import type { PluginContext } from '#/index.js';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const SHIMS_DIR = resolve(__dirname, '..', 'shims');
18
+
19
+ /**
20
+ * Virtual module IDs for server-only and client-only poison pills.
21
+ *
22
+ * These packages cause build errors when imported in the wrong environment:
23
+ * - `server-only` errors when imported in a client component
24
+ * - `client-only` errors when imported in a server component
25
+ */
26
+ const SERVER_ONLY_VIRTUAL = '\0timber:server-only';
27
+ const CLIENT_ONLY_VIRTUAL = '\0timber:client-only';
28
+
29
+ /**
30
+ * Map from next/* import specifiers to shim file paths.
31
+ *
32
+ * The shim map is a separate data structure (not embedded in the plugin)
33
+ * per the task's approach constraints.
34
+ */
35
+ const SHIM_MAP: Record<string, string> = {
36
+ 'next/link': resolve(SHIMS_DIR, 'link.ts'),
37
+ 'next/image': resolve(SHIMS_DIR, 'image.ts'),
38
+ 'next/navigation': resolve(SHIMS_DIR, 'navigation.ts'),
39
+ 'next/headers': resolve(SHIMS_DIR, 'headers.ts'),
40
+ // next/font/* redirects to the timber-fonts virtual modules.
41
+ // The fonts plugin's load hook serves the actual module code.
42
+ 'next/font/google': '\0@timber/fonts/google',
43
+ 'next/font/local': '\0@timber/fonts/local',
44
+ };
45
+
46
+ /**
47
+ * Client-only shim overrides for the browser environment.
48
+ *
49
+ * next/navigation in the client environment resolves to navigation-client.ts
50
+ * which only re-exports client hooks — not server functions like redirect()
51
+ * and deny(). This prevents server/primitives.ts from being pulled into the
52
+ * browser bundle via tree-shaking-resistant imports.
53
+ */
54
+ const CLIENT_SHIM_OVERRIDES: Record<string, string> = {
55
+ 'next/navigation': resolve(SHIMS_DIR, 'navigation-client.ts'),
56
+ };
57
+
58
+ /**
59
+ * Map from @timber/app/* subpath imports to real source files.
60
+ *
61
+ * These resolve subpath imports like `@timber/app/server` to the
62
+ * real entry files in the package source.
63
+ */
64
+ const TIMBER_SUBPATH_MAP: Record<string, string> = {
65
+ '@timber/app/server': resolve(__dirname, '..', 'server', 'index.ts'),
66
+ '@timber/app/client': resolve(__dirname, '..', 'client', 'index.ts'),
67
+ '@timber/app/cache': resolve(__dirname, '..', 'cache', 'index.ts'),
68
+ '@timber/app/search-params': resolve(__dirname, '..', 'search-params', 'index.ts'),
69
+ '@timber/app/routing': resolve(__dirname, '..', 'routing', 'index.ts'),
70
+ };
71
+
72
+ /**
73
+ * Strip .js extension from an import specifier.
74
+ *
75
+ * Libraries like nuqs import `next/navigation.js` with an explicit
76
+ * extension. We strip it before matching against the shim map.
77
+ */
78
+ function stripJsExtension(id: string): string {
79
+ return id.endsWith('.js') ? id.slice(0, -3) : id;
80
+ }
81
+
82
+ /**
83
+ * Create the timber-shims Vite plugin.
84
+ *
85
+ * Hooks: resolveId, load
86
+ */
87
+ export function timberShims(_ctx: PluginContext): Plugin {
88
+ return {
89
+ name: 'timber-shims',
90
+ // Must run before Vite's built-in resolution so that server-only/client-only
91
+ // poison pills are intercepted even when imported from node_modules deps
92
+ // (e.g. bright, next-intl). Without this, the dep optimizer resolves to the
93
+ // real CJS package which throws at runtime in the SSR environment.
94
+ enforce: 'pre',
95
+
96
+ /**
97
+ * Resolve next/* and @timber/app/* imports to shim/source files.
98
+ *
99
+ * Resolution order:
100
+ * 1. Check server-only / client-only poison pill packages
101
+ * 2. Strip .js extension from the import specifier
102
+ * 3. Check next/* shim map
103
+ * 4. Check @timber/app/* subpath map
104
+ * 5. Return null (pass through) for unrecognized imports
105
+ */
106
+ resolveId(id: string) {
107
+ // Poison pill packages — resolve to virtual modules handled by load()
108
+ if (id === 'server-only') return SERVER_ONLY_VIRTUAL;
109
+ if (id === 'client-only') return CLIENT_ONLY_VIRTUAL;
110
+
111
+ const cleanId = stripJsExtension(id);
112
+
113
+ // Check next/* shim map.
114
+ // In the client (browser) environment, use client-only shim overrides
115
+ // to avoid pulling server code (primitives.ts) into the browser bundle.
116
+ if (cleanId in SHIM_MAP) {
117
+ const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
118
+ if (envName === 'client' && cleanId in CLIENT_SHIM_OVERRIDES) {
119
+ return CLIENT_SHIM_OVERRIDES[cleanId];
120
+ }
121
+ return SHIM_MAP[cleanId];
122
+ }
123
+
124
+ // Check @timber/app/* subpath map
125
+ if (cleanId in TIMBER_SUBPATH_MAP) {
126
+ return TIMBER_SUBPATH_MAP[cleanId];
127
+ }
128
+
129
+ return null;
130
+ },
131
+
132
+ /**
133
+ * Serve virtual modules for server-only / client-only poison pills.
134
+ *
135
+ * In the correct environment, the module is a no-op (empty export).
136
+ * In the wrong environment, it throws a build-time error message that
137
+ * clearly identifies the boundary violation.
138
+ */
139
+ load(id: string) {
140
+ const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
141
+ const isClient = envName === 'client';
142
+
143
+ if (id === SERVER_ONLY_VIRTUAL) {
144
+ if (isClient) {
145
+ return `throw new Error(
146
+ "This module cannot be imported from a Client Component module. " +
147
+ "It should only be used from a Server Component."
148
+ );`;
149
+ }
150
+ // No-op in server environments (rsc, ssr)
151
+ return 'export {};';
152
+ }
153
+
154
+ if (id === CLIENT_ONLY_VIRTUAL) {
155
+ if (!isClient) {
156
+ return `throw new Error(
157
+ "This module cannot be imported from a Server Component module. " +
158
+ "It should only be used from a Client Component."
159
+ );`;
160
+ }
161
+ // No-op in client environment
162
+ return 'export {};';
163
+ }
164
+
165
+ return null;
166
+ },
167
+ };
168
+ }