@timber-js/app 0.1.0 → 0.1.2
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.
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +43 -58
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +413 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +389 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +207 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- package/src/utils/startup-timer.ts +102 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { CacheHandler } from './index';
|
|
3
|
+
import { stableStringify } from './stable-stringify';
|
|
4
|
+
import { createSingleflight } from './singleflight';
|
|
5
|
+
|
|
6
|
+
const singleflight = createSingleflight();
|
|
7
|
+
|
|
8
|
+
// Prop names that suggest request-specific data — triggers dev warning for "use cache" components.
|
|
9
|
+
const REQUEST_SPECIFIC_PROPS = new Set([
|
|
10
|
+
'cookies',
|
|
11
|
+
'cookie',
|
|
12
|
+
'session',
|
|
13
|
+
'sessionId',
|
|
14
|
+
'token',
|
|
15
|
+
'authorization',
|
|
16
|
+
'auth',
|
|
17
|
+
'headers',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
export interface RegisterCachedFunctionOptions<Fn extends (...args: any[]) => any> {
|
|
22
|
+
ttl: number;
|
|
23
|
+
id: string;
|
|
24
|
+
tags?: string[] | ((...args: Parameters<Fn>) => string[]);
|
|
25
|
+
/** True when the cached function is a React component (PascalCase name). */
|
|
26
|
+
isComponent?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Generate a SHA-256 cache key from a stable function ID and serialized args.
|
|
31
|
+
*/
|
|
32
|
+
function generateKey(id: string, args: unknown[]): string {
|
|
33
|
+
const raw = id + ':' + stableStringify(args);
|
|
34
|
+
return createHash('sha256').update(raw).digest('hex');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve tags from options — supports static array or function form.
|
|
39
|
+
*/
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
41
|
+
function resolveTags<Fn extends (...args: any[]) => any>(
|
|
42
|
+
opts: RegisterCachedFunctionOptions<Fn>,
|
|
43
|
+
args: Parameters<Fn>
|
|
44
|
+
): string[] {
|
|
45
|
+
if (!opts.tags) return [];
|
|
46
|
+
if (Array.isArray(opts.tags)) return opts.tags;
|
|
47
|
+
return opts.tags(...args);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Checks if component props contain request-specific keys and emits a dev warning.
|
|
52
|
+
* Only runs when process.env.NODE_ENV !== 'production'.
|
|
53
|
+
*/
|
|
54
|
+
function warnRequestSpecificProps(id: string, props: unknown): void {
|
|
55
|
+
if (typeof props !== 'object' || props === null) return;
|
|
56
|
+
const keys = Object.keys(props);
|
|
57
|
+
const suspicious = keys.filter((k) => REQUEST_SPECIFIC_PROPS.has(k.toLowerCase()));
|
|
58
|
+
if (suspicious.length > 0) {
|
|
59
|
+
console.warn(
|
|
60
|
+
`[timber] "use cache" component ${id} received request-specific props: ${suspicious.join(', ')}. ` +
|
|
61
|
+
`This may serve one user's cached render to another user. ` +
|
|
62
|
+
`Remove request-specific data from props or remove "use cache".`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Runtime for the "use cache" directive transform. Wraps an async function
|
|
69
|
+
* with caching using the same cache handler as timber.cache.
|
|
70
|
+
*
|
|
71
|
+
* The stable `id` (file path + function name) ensures cache keys are consistent
|
|
72
|
+
* across builds. Args/props are hashed with SHA-256 for the per-call key.
|
|
73
|
+
*/
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
75
|
+
export function registerCachedFunction<Fn extends (...args: any[]) => Promise<any>>(
|
|
76
|
+
fn: Fn,
|
|
77
|
+
opts: RegisterCachedFunctionOptions<Fn>,
|
|
78
|
+
handler: CacheHandler
|
|
79
|
+
): (...args: Parameters<Fn>) => Promise<Awaited<ReturnType<Fn>>> {
|
|
80
|
+
return async (...args: Parameters<Fn>): Promise<Awaited<ReturnType<Fn>>> => {
|
|
81
|
+
// Dev-mode warning for components with request-specific props
|
|
82
|
+
if (opts.isComponent && process.env.NODE_ENV !== 'production' && args.length > 0) {
|
|
83
|
+
warnRequestSpecificProps(opts.id, args[0]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const key = generateKey(opts.id, args);
|
|
87
|
+
const cached = await handler.get(key);
|
|
88
|
+
|
|
89
|
+
if (cached && !cached.stale) {
|
|
90
|
+
return cached.value as Awaited<ReturnType<Fn>>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Cache miss or stale — execute with singleflight
|
|
94
|
+
const result = await singleflight.do(key, () => fn(...args));
|
|
95
|
+
const tags = resolveTags(opts, args);
|
|
96
|
+
await handler.set(key, result, { ttl: opts.ttl, tags });
|
|
97
|
+
return result as Awaited<ReturnType<Fn>>;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Singleflight coalesces concurrent calls with the same key into a single
|
|
3
|
+
* execution. All callers receive the same result (or error).
|
|
4
|
+
*
|
|
5
|
+
* Per-process, in-memory. Each process coalesces independently.
|
|
6
|
+
*/
|
|
7
|
+
export interface Singleflight {
|
|
8
|
+
do<T>(key: string, fn: () => Promise<T>): Promise<T>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createSingleflight(): Singleflight {
|
|
12
|
+
const inflight = new Map<string, Promise<unknown>>();
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
do<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
16
|
+
const existing = inflight.get(key);
|
|
17
|
+
if (existing) return existing as Promise<T>;
|
|
18
|
+
|
|
19
|
+
const promise = fn().finally(() => {
|
|
20
|
+
inflight.delete(key);
|
|
21
|
+
});
|
|
22
|
+
inflight.set(key, promise);
|
|
23
|
+
return promise;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic JSON serialization with sorted object keys.
|
|
3
|
+
* Used for cache key generation — ensures { a: 1, b: 2 } and { b: 2, a: 1 }
|
|
4
|
+
* produce the same string.
|
|
5
|
+
*/
|
|
6
|
+
export function stableStringify(value: unknown): string {
|
|
7
|
+
if (value === null || value === undefined) return JSON.stringify(value);
|
|
8
|
+
if (typeof value !== 'object') return JSON.stringify(value);
|
|
9
|
+
if (Array.isArray(value)) {
|
|
10
|
+
return '[' + value.map((item) => stableStringify(item)).join(',') + ']';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const obj = value as Record<string, unknown>;
|
|
14
|
+
const keys = Object.keys(obj).sort();
|
|
15
|
+
const pairs: string[] = [];
|
|
16
|
+
for (const key of keys) {
|
|
17
|
+
if (obj[key] === undefined) continue;
|
|
18
|
+
pairs.push(JSON.stringify(key) + ':' + stableStringify(obj[key]));
|
|
19
|
+
}
|
|
20
|
+
return '{' + pairs.join(',') + '}';
|
|
21
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { CacheHandler, CacheOptions } from './index';
|
|
3
|
+
import { stableStringify } from './stable-stringify';
|
|
4
|
+
import { createSingleflight } from './singleflight';
|
|
5
|
+
import { addSpanEvent } from '#/server/tracing.js';
|
|
6
|
+
|
|
7
|
+
const singleflight = createSingleflight();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate a SHA-256 cache key from function identity and serialized args.
|
|
11
|
+
*/
|
|
12
|
+
function defaultKeyGenerator(fnId: string, args: unknown[]): string {
|
|
13
|
+
const raw = fnId + ':' + stableStringify(args);
|
|
14
|
+
return createHash('sha256').update(raw).digest('hex');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve tags from the options — supports static array or function form.
|
|
19
|
+
*/
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
21
|
+
function resolveTags<Fn extends (...args: any[]) => any>(
|
|
22
|
+
opts: CacheOptions<Fn>,
|
|
23
|
+
args: Parameters<Fn>
|
|
24
|
+
): string[] {
|
|
25
|
+
if (!opts.tags) return [];
|
|
26
|
+
if (Array.isArray(opts.tags)) return opts.tags;
|
|
27
|
+
return opts.tags(...args);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Counter for generating unique function IDs when no explicit key is provided.
|
|
31
|
+
let fnIdCounter = 0;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a cached wrapper around an async function.
|
|
35
|
+
*
|
|
36
|
+
* - SHA-256 default keys with normalized JSON args
|
|
37
|
+
* - Singleflight: concurrent misses → single execution
|
|
38
|
+
* - SWR: serve stale immediately, background refetch
|
|
39
|
+
* - Tags as string[] or function of args
|
|
40
|
+
* - No ALS dependency
|
|
41
|
+
*
|
|
42
|
+
* Cache hits/misses are recorded as OTEL span events on the enclosing
|
|
43
|
+
* span (not child spans). The DevSpanProcessor reads these for dev log output.
|
|
44
|
+
*/
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
+
export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
47
|
+
fn: Fn,
|
|
48
|
+
opts: CacheOptions<Fn>,
|
|
49
|
+
handler: CacheHandler
|
|
50
|
+
): (...args: Parameters<Fn>) => Promise<Awaited<ReturnType<Fn>>> {
|
|
51
|
+
const fnId = `timber-cache:${fnIdCounter++}`;
|
|
52
|
+
|
|
53
|
+
return async (...args: Parameters<Fn>): Promise<Awaited<ReturnType<Fn>>> => {
|
|
54
|
+
const key = opts.key ? opts.key(...args) : defaultKeyGenerator(fnId, args);
|
|
55
|
+
|
|
56
|
+
const cacheStart = performance.now();
|
|
57
|
+
const cached = await handler.get(key);
|
|
58
|
+
|
|
59
|
+
if (cached && !cached.stale) {
|
|
60
|
+
// Record as OTEL span event on enclosing span (not a child span)
|
|
61
|
+
await addSpanEvent('timber.cache.hit', {
|
|
62
|
+
key,
|
|
63
|
+
duration_ms: Math.round(performance.now() - cacheStart),
|
|
64
|
+
});
|
|
65
|
+
return cached.value as Awaited<ReturnType<Fn>>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (cached && cached.stale && opts.staleWhileRevalidate) {
|
|
69
|
+
// Record stale cache hit as OTEL span event
|
|
70
|
+
await addSpanEvent('timber.cache.hit', {
|
|
71
|
+
key,
|
|
72
|
+
duration_ms: Math.round(performance.now() - cacheStart),
|
|
73
|
+
stale: true,
|
|
74
|
+
});
|
|
75
|
+
// Serve stale immediately, trigger background refetch
|
|
76
|
+
singleflight
|
|
77
|
+
.do(`swr:${key}`, async () => {
|
|
78
|
+
try {
|
|
79
|
+
const fresh = await fn(...args);
|
|
80
|
+
const tags = resolveTags(opts, args);
|
|
81
|
+
await handler.set(key, fresh, { ttl: opts.ttl, tags });
|
|
82
|
+
} catch {
|
|
83
|
+
// Failed refetch — stale entry continues to be served.
|
|
84
|
+
// Error is swallowed per design doc: "Error is logged."
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
.catch(() => {
|
|
88
|
+
// Singleflight promise rejection handled — stale continues.
|
|
89
|
+
});
|
|
90
|
+
return cached.value as Awaited<ReturnType<Fn>>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Cache miss (or stale without SWR) — execute with singleflight
|
|
94
|
+
const result = await singleflight.do(key, () => fn(...args));
|
|
95
|
+
const tags = resolveTags(opts, args);
|
|
96
|
+
await handler.set(key, result, { ttl: opts.ttl, tags });
|
|
97
|
+
|
|
98
|
+
// Record cache miss as OTEL span event
|
|
99
|
+
await addSpanEvent('timber.cache.miss', {
|
|
100
|
+
key,
|
|
101
|
+
duration_ms: Math.round(performance.now() - cacheStart),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return result as Awaited<ReturnType<Fn>>;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Invalidate cache entries by tag or key.
|
|
110
|
+
*/
|
|
111
|
+
createCache.invalidate = async function invalidate(
|
|
112
|
+
handler: CacheHandler,
|
|
113
|
+
opts: { key?: string; tag?: string }
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
await handler.invalidate(opts);
|
|
116
|
+
};
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// timber.js CLI
|
|
4
|
+
//
|
|
5
|
+
// Wraps Vite commands with timber-specific behavior.
|
|
6
|
+
// See design/18-build-system.md §"CLI".
|
|
7
|
+
//
|
|
8
|
+
// Commands:
|
|
9
|
+
// timber dev — Start Vite dev server with HMR
|
|
10
|
+
// timber build — Run multi-environment build via createBuilder/buildApp
|
|
11
|
+
// timber preview — Serve the production build
|
|
12
|
+
// timber check — Validate types + routes without building
|
|
13
|
+
|
|
14
|
+
const COMMANDS = ['dev', 'build', 'preview', 'check'] as const;
|
|
15
|
+
type Command = (typeof COMMANDS)[number];
|
|
16
|
+
|
|
17
|
+
export interface ParsedArgs {
|
|
18
|
+
command: Command;
|
|
19
|
+
config: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CommandOptions {
|
|
23
|
+
config?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse CLI arguments into a structured command + options.
|
|
28
|
+
* Accepts: timber <command> [--config|-c <path>]
|
|
29
|
+
*/
|
|
30
|
+
export function parseArgs(args: string[]): ParsedArgs {
|
|
31
|
+
if (args.length === 0) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
'No command provided. Usage: timber <dev|build|preview|check> [--config <path>]'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const command = args[0];
|
|
38
|
+
if (!COMMANDS.includes(command as Command)) {
|
|
39
|
+
throw new Error(`Unknown command: ${command}. Available commands: ${COMMANDS.join(', ')}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let config: string | undefined;
|
|
43
|
+
for (let i = 1; i < args.length; i++) {
|
|
44
|
+
if (args[i] === '--config' || args[i] === '-c') {
|
|
45
|
+
config = args[++i];
|
|
46
|
+
if (!config) {
|
|
47
|
+
throw new Error('--config requires a path argument');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { command: command as Command, config };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Command Implementations ─────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Start the Vite dev server.
|
|
59
|
+
* Middleware re-runs on file change via HMR wiring in timber-routing.
|
|
60
|
+
*/
|
|
61
|
+
export async function runDev(options: CommandOptions): Promise<void> {
|
|
62
|
+
const { createServer } = await import('vite');
|
|
63
|
+
const server = await createServer({
|
|
64
|
+
configFile: options.config,
|
|
65
|
+
});
|
|
66
|
+
await server.listen();
|
|
67
|
+
server.printUrls();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Run the production build using createBuilder + buildApp.
|
|
72
|
+
* Direct build() calls do NOT trigger the RSC plugin's multi-environment
|
|
73
|
+
* pipeline — createBuilder/buildApp is required.
|
|
74
|
+
*/
|
|
75
|
+
export async function runBuild(options: CommandOptions): Promise<void> {
|
|
76
|
+
const { createBuilder } = await import('vite');
|
|
77
|
+
const builder = await createBuilder({
|
|
78
|
+
configFile: options.config,
|
|
79
|
+
});
|
|
80
|
+
await builder.buildApp();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Determine whether to use the adapter's preview or Vite's built-in preview.
|
|
85
|
+
* Exported for testing — the actual runPreview function uses this internally.
|
|
86
|
+
*/
|
|
87
|
+
export function resolvePreviewStrategy(
|
|
88
|
+
adapter: import('./adapters/types').TimberPlatformAdapter | undefined
|
|
89
|
+
): 'adapter' | 'vite' {
|
|
90
|
+
if (adapter && typeof adapter.preview === 'function') {
|
|
91
|
+
return 'adapter';
|
|
92
|
+
}
|
|
93
|
+
return 'vite';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Load timber.config.ts from the project root.
|
|
98
|
+
* Returns the config object with adapter, output, etc.
|
|
99
|
+
* Returns null if no config file is found.
|
|
100
|
+
*/
|
|
101
|
+
async function loadTimberConfig(
|
|
102
|
+
root: string
|
|
103
|
+
): Promise<{ adapter?: import('./adapters/types').TimberPlatformAdapter; output?: string } | null> {
|
|
104
|
+
const { existsSync } = await import('node:fs');
|
|
105
|
+
const { join } = await import('node:path');
|
|
106
|
+
const { pathToFileURL } = await import('node:url');
|
|
107
|
+
|
|
108
|
+
const configNames = ['timber.config.ts', 'timber.config.js', 'timber.config.mjs'];
|
|
109
|
+
|
|
110
|
+
for (const name of configNames) {
|
|
111
|
+
const configPath = join(root, name);
|
|
112
|
+
if (existsSync(configPath)) {
|
|
113
|
+
// Use Vite's built-in config loading to handle TypeScript
|
|
114
|
+
const mod = await import(pathToFileURL(configPath).href);
|
|
115
|
+
return mod.default ?? mod;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Serve the production build for local testing.
|
|
123
|
+
* If the adapter provides a preview() method, it takes priority.
|
|
124
|
+
* Otherwise falls back to Vite's built-in preview server.
|
|
125
|
+
*/
|
|
126
|
+
export async function runPreview(options: CommandOptions): Promise<void> {
|
|
127
|
+
const { join } = await import('node:path');
|
|
128
|
+
|
|
129
|
+
// Try to load timber config for adapter-specific preview
|
|
130
|
+
const root = process.cwd();
|
|
131
|
+
const config = await loadTimberConfig(root).catch(() => null);
|
|
132
|
+
const adapter = config?.adapter as import('./adapters/types').TimberPlatformAdapter | undefined;
|
|
133
|
+
|
|
134
|
+
if (resolvePreviewStrategy(adapter) === 'adapter') {
|
|
135
|
+
const buildDir = join(root, '.timber', 'build');
|
|
136
|
+
const timberConfig = { output: (config?.output ?? 'server') as 'server' | 'static' };
|
|
137
|
+
await adapter!.preview!(timberConfig, buildDir);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Fallback: Vite's built-in preview server
|
|
142
|
+
const { preview } = await import('vite');
|
|
143
|
+
const server = await preview({
|
|
144
|
+
configFile: options.config,
|
|
145
|
+
});
|
|
146
|
+
server.printUrls();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Validate types and routes without producing build output.
|
|
151
|
+
* Runs tsgo --noEmit for type checking.
|
|
152
|
+
*/
|
|
153
|
+
export async function runCheck(options: CommandOptions): Promise<void> {
|
|
154
|
+
const { execFile } = await import('node:child_process');
|
|
155
|
+
|
|
156
|
+
await new Promise<void>((resolve, reject) => {
|
|
157
|
+
const configArgs = options.config ? ['--project', options.config] : [];
|
|
158
|
+
execFile('tsgo', ['--noEmit', ...configArgs], (err, stdout, stderr) => {
|
|
159
|
+
if (stdout) process.stdout.write(stdout);
|
|
160
|
+
if (stderr) process.stderr.write(stderr);
|
|
161
|
+
if (err) {
|
|
162
|
+
reject(new Error(`Type check failed with exit code ${err.code}`));
|
|
163
|
+
} else {
|
|
164
|
+
resolve();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Main Entry Point ────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
async function main(): Promise<void> {
|
|
173
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
174
|
+
const options: CommandOptions = { config: parsed.config };
|
|
175
|
+
|
|
176
|
+
switch (parsed.command) {
|
|
177
|
+
case 'dev':
|
|
178
|
+
await runDev(options);
|
|
179
|
+
break;
|
|
180
|
+
case 'build':
|
|
181
|
+
await runBuild(options);
|
|
182
|
+
break;
|
|
183
|
+
case 'preview':
|
|
184
|
+
await runPreview(options);
|
|
185
|
+
break;
|
|
186
|
+
case 'check':
|
|
187
|
+
await runCheck(options);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Only run main when executed directly (not imported in tests)
|
|
193
|
+
const isDirectExecution =
|
|
194
|
+
typeof process !== 'undefined' && process.argv[1] && import.meta.url.endsWith(process.argv[1]);
|
|
195
|
+
|
|
196
|
+
if (isDirectExecution) {
|
|
197
|
+
main().catch((err) => {
|
|
198
|
+
console.error(err.message);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
});
|
|
201
|
+
}
|