@timber-js/app 0.2.0-alpha.146 → 0.2.0-alpha.148
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/_chunks/{actions-OIZ8siQu.js → actions-a0RczDnB.js} +8 -34
- package/dist/_chunks/{actions-OIZ8siQu.js.map → actions-a0RczDnB.js.map} +1 -1
- package/dist/_chunks/cache-api-Co-yI-gP.js +424 -0
- package/dist/_chunks/cache-api-Co-yI-gP.js.map +1 -0
- package/dist/_chunks/{metadata-routes-BjepjefT.js → canonicalize-P41GR6tY.js} +65 -2
- package/dist/_chunks/canonicalize-P41GR6tY.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-D2IpXicg.js → cli-schema-sync-DJDdyEae.js} +157 -313
- package/dist/_chunks/cli-schema-sync-DJDdyEae.js.map +1 -0
- package/dist/_chunks/cli-schema-sync-D_IhHEUW.js +2 -0
- package/dist/_chunks/{define-DMVhsipD.js → define-Bssfp6ot.js} +3 -33
- package/dist/_chunks/define-Bssfp6ot.js.map +1 -0
- package/dist/_chunks/{define-C3RvHQPK.js → define-CFmvb4Bt.js} +15 -7
- package/dist/_chunks/define-CFmvb4Bt.js.map +1 -0
- package/dist/_chunks/{href-validation-DyZIzfMJ.js → href-validation-FiA_rToW.js} +2 -1
- package/dist/_chunks/{href-validation-DyZIzfMJ.js.map → href-validation-FiA_rToW.js.map} +1 -1
- package/dist/_chunks/{tracing-M_zKubhk.js → logger-CM0clugc.js} +394 -107
- package/dist/_chunks/logger-CM0clugc.js.map +1 -0
- package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -1
- package/dist/_chunks/plugin-context-DurqFBuY.js +2 -0
- package/dist/_chunks/plugin-context-GfDWsdd2.js +268 -0
- package/dist/_chunks/plugin-context-GfDWsdd2.js.map +1 -0
- package/dist/_chunks/{resolve-schema-Dj8tQloi.js → resolve-schema-3iUvBV5T.js} +3 -30
- package/dist/_chunks/resolve-schema-3iUvBV5T.js.map +1 -0
- package/dist/_chunks/{schema-bridge-DK_89h8O.js → schema-bridge-BY3QLBL7.js} +35 -2
- package/dist/_chunks/schema-bridge-BY3QLBL7.js.map +1 -0
- package/dist/_chunks/tree-match-D2l830j2.js +102 -0
- package/dist/_chunks/tree-match-D2l830j2.js.map +1 -0
- package/dist/_chunks/use-segment-params-D1CzlgBo.js.map +1 -1
- package/dist/_chunks/{walkers-7FBPM-hX.js → walkers-DvWDj453.js} +154 -88
- package/dist/_chunks/walkers-DvWDj453.js.map +1 -0
- package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +36 -14
- package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
- package/dist/adapters/compress-module.d.ts +5 -4
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.d.ts +1 -3
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +14 -134
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/cache-api.d.ts.map +1 -1
- package/dist/cache/index.d.ts +3 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +20 -235
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/invalidation-epoch.d.ts +43 -0
- package/dist/cache/invalidation-epoch.d.ts.map +1 -0
- package/dist/cache/redis-handler.d.ts +13 -0
- package/dist/cache/redis-handler.d.ts.map +1 -1
- package/dist/cache/timber-cache.d.ts +17 -9
- package/dist/cache/timber-cache.d.ts.map +1 -1
- package/dist/cli-schema-sync.d.ts +21 -1
- package/dist/cli-schema-sync.d.ts.map +1 -1
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +26 -31
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/hmr.d.ts.map +1 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +13 -4
- package/dist/client/error-boundary.js.map +1 -1
- package/dist/client/error-reconstituter.d.ts +8 -1
- package/dist/client/error-reconstituter.d.ts.map +1 -1
- package/dist/client/history.d.ts +10 -17
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +30 -33
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-api.d.ts +9 -2
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +0 -11
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +0 -5
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/use-search-params.d.ts +10 -5
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +2 -4
- package/dist/cookies/index.js.map +1 -1
- package/dist/dev-tools/dev-page-shell.d.ts +1 -0
- package/dist/dev-tools/dev-page-shell.d.ts.map +1 -1
- package/dist/dev-tools/hydration-overlay.d.ts +11 -0
- package/dist/dev-tools/hydration-overlay.d.ts.map +1 -0
- package/dist/dev-tools/index.d.ts +1 -2
- package/dist/dev-tools/index.d.ts.map +1 -1
- package/dist/fonts/ast-walk.d.ts +68 -0
- package/dist/fonts/ast-walk.d.ts.map +1 -0
- package/dist/fonts/ast.d.ts +23 -11
- package/dist/fonts/ast.d.ts.map +1 -1
- package/dist/fonts/bundle.d.ts +3 -1
- package/dist/fonts/bundle.d.ts.map +1 -1
- package/dist/fonts/dev-middleware.d.ts +1 -1
- package/dist/fonts/dev-middleware.d.ts.map +1 -1
- package/dist/fonts/fallbacks.d.ts +0 -7
- package/dist/fonts/fallbacks.d.ts.map +1 -1
- package/dist/fonts/google.d.ts +4 -2
- package/dist/fonts/google.d.ts.map +1 -1
- package/dist/fonts/local.d.ts +3 -3
- package/dist/fonts/local.d.ts.map +1 -1
- package/dist/fonts/paths.d.ts +43 -0
- package/dist/fonts/paths.d.ts.map +1 -0
- package/dist/fonts/pipeline.d.ts +7 -21
- package/dist/fonts/pipeline.d.ts.map +1 -1
- package/dist/fonts/transform.d.ts.map +1 -1
- package/dist/fonts/virtual-modules.d.ts +1 -18
- package/dist/fonts/virtual-modules.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +609 -406
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/params/resolve-schema.d.ts.map +1 -1
- package/dist/plugin-context.d.ts +28 -5
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/cache.d.ts +13 -5
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts +1 -2
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/routing/codegen-types.d.ts +4 -0
- package/dist/routing/codegen-types.d.ts.map +1 -1
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/file-cache.d.ts +8 -0
- package/dist/routing/file-cache.d.ts.map +1 -0
- package/dist/routing/index.js +2 -2
- package/dist/routing/interception.d.ts +0 -5
- package/dist/routing/interception.d.ts.map +1 -1
- package/dist/routing/manifest-codegen.d.ts +32 -0
- package/dist/routing/manifest-codegen.d.ts.map +1 -0
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/schema-bridge.d.ts +15 -0
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/define.d.ts.map +1 -1
- package/dist/search-params/index.js +7 -3
- package/dist/search-params/index.js.map +1 -1
- package/dist/search-params/wrappers.d.ts.map +1 -1
- package/dist/segment-params/define.d.ts.map +1 -1
- package/dist/segment-params/index.js +1 -1
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/actions.d.ts +0 -14
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +0 -29
- package/dist/server/build-manifest.d.ts.map +1 -1
- package/dist/server/canonicalize.d.ts +10 -0
- package/dist/server/canonicalize.d.ts.map +1 -1
- package/dist/server/deny-boundary.d.ts +27 -2
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts +8 -0
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.d.ts +1 -5
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +238 -349
- package/dist/server/internal.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +0 -11
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/render-utils.d.ts +25 -0
- package/dist/server/render-utils.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +11 -25
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +2 -18
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/server/status-code-resolver.d.ts +6 -0
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/utils/mdx-file.d.ts +15 -0
- package/dist/server/utils/mdx-file.d.ts.map +1 -0
- package/dist/shared/href-validation.d.ts.map +1 -1
- package/dist/shims/image.d.ts +15 -15
- package/package.json +3 -3
- package/src/adapters/cloudflare-kv-cache.ts +56 -19
- package/src/adapters/compress-module.ts +27 -118
- package/src/adapters/nitro.ts +3 -41
- package/src/adapters/raw-imports.d.ts +5 -0
- package/src/cache/cache-api.ts +9 -1
- package/src/cache/index.ts +5 -2
- package/src/cache/invalidation-epoch.ts +93 -0
- package/src/cache/redis-handler.ts +33 -2
- package/src/cache/timber-cache.ts +109 -53
- package/src/cli-schema-sync.ts +123 -30
- package/src/cli.ts +33 -37
- package/src/client/browser-entry/hmr.ts +0 -11
- package/src/client/browser-entry/hydrate.ts +39 -5
- package/src/client/browser-entry/post-hydration.ts +6 -15
- package/src/client/browser-entry/router-init.ts +98 -3
- package/src/client/error-boundary.tsx +14 -0
- package/src/client/error-reconstituter.tsx +15 -1
- package/src/client/history.ts +10 -24
- package/src/client/navigation-api.ts +14 -9
- package/src/client/navigation-context.ts +1 -21
- package/src/client/navigation-root.tsx +0 -8
- package/src/client/router.ts +18 -8
- package/src/client/rsc-fetch.ts +7 -2
- package/src/client/use-search-params.ts +10 -5
- package/src/config-validation.ts +33 -25
- package/src/cookies/define-cookie.ts +2 -18
- package/src/dev-tools/dev-page-shell.ts +6 -2
- package/src/dev-tools/hydration-overlay.ts +158 -0
- package/src/dev-tools/index.ts +3 -16
- package/src/fonts/ast-walk.ts +235 -0
- package/src/fonts/ast.ts +91 -249
- package/src/fonts/bundle.ts +16 -9
- package/src/fonts/dev-middleware.ts +16 -5
- package/src/fonts/fallbacks.ts +0 -23
- package/src/fonts/google.ts +7 -3
- package/src/fonts/local.ts +7 -6
- package/src/fonts/paths.ts +58 -0
- package/src/fonts/pipeline.ts +27 -48
- package/src/fonts/transform.ts +27 -0
- package/src/fonts/virtual-modules.ts +1 -57
- package/src/index.ts +5 -11
- package/src/params/resolve-schema.ts +2 -34
- package/src/plugin-context.ts +95 -17
- package/src/plugins/cache.ts +64 -17
- package/src/plugins/dev-server.ts +61 -9
- package/src/plugins/fonts.ts +21 -6
- package/src/plugins/routing.ts +5 -348
- package/src/routing/codegen-types.ts +4 -0
- package/src/routing/codegen.ts +66 -8
- package/src/routing/convention-lint.ts +4 -3
- package/src/routing/file-cache.ts +23 -0
- package/src/routing/interception.ts +2 -21
- package/src/routing/manifest-codegen.ts +290 -0
- package/src/routing/scanner.ts +85 -80
- package/src/routing/schema-validation.ts +2 -2
- package/src/schema-bridge.ts +63 -2
- package/src/search-params/define.ts +34 -2
- package/src/search-params/wrappers.ts +21 -3
- package/src/segment-params/define.ts +2 -62
- package/src/server/access-gate.tsx +8 -0
- package/src/server/actions.ts +8 -31
- package/src/server/build-manifest.ts +0 -56
- package/src/server/canonicalize.ts +6 -3
- package/src/server/deny-boundary.ts +106 -13
- package/src/server/deny-renderer.ts +46 -10
- package/src/server/error-boundary-wrapper.ts +1 -14
- package/src/server/html-injector-core.ts +19 -0
- package/src/server/html-injectors.ts +14 -4
- package/src/server/internal.ts +1 -18
- package/src/server/node-stream-transforms.ts +5 -72
- package/src/server/render-utils.ts +31 -0
- package/src/server/route-element-builder.ts +77 -47
- package/src/server/route-matcher.ts +22 -6
- package/src/server/rsc-entry/error-renderer.ts +2 -12
- package/src/server/rsc-entry/helpers.ts +6 -34
- package/src/server/rsc-entry/index.ts +11 -0
- package/src/server/rsc-entry/render-route.ts +7 -9
- package/src/server/rsc-entry/rsc-payload.ts +2 -1
- package/src/server/rsc-entry/ssr-renderer.ts +2 -2
- package/src/server/ssr-render.ts +1 -11
- package/src/server/status-code-resolver.ts +3 -2
- package/src/server/tree-match.ts +15 -5
- package/src/server/utils/mdx-file.ts +22 -0
- package/src/shared/href-validation.ts +8 -0
- package/dist/_chunks/cli-schema-sync-D2IpXicg.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-DH-M8Poy.js +0 -2
- package/dist/_chunks/define-C3RvHQPK.js.map +0 -1
- package/dist/_chunks/define-DMVhsipD.js.map +0 -1
- package/dist/_chunks/handler-store-BVDgyTh7.js +0 -78
- package/dist/_chunks/handler-store-BVDgyTh7.js.map +0 -1
- package/dist/_chunks/logger-Dt-jQaBO.js +0 -291
- package/dist/_chunks/logger-Dt-jQaBO.js.map +0 -1
- package/dist/_chunks/metadata-routes-BjepjefT.js.map +0 -1
- package/dist/_chunks/resolve-schema-Dj8tQloi.js.map +0 -1
- package/dist/_chunks/schema-bridge-DK_89h8O.js.map +0 -1
- package/dist/_chunks/tracing-M_zKubhk.js.map +0 -1
- package/dist/_chunks/walkers-7FBPM-hX.js.map +0 -1
- package/dist/_chunks/warnings-CNPXaypM.js +0 -125
- package/dist/_chunks/warnings-CNPXaypM.js.map +0 -1
- package/dist/client/link-pending-store.d.ts +0 -65
- package/dist/client/link-pending-store.d.ts.map +0 -1
- package/dist/client/nav-link-store.d.ts +0 -27
- package/dist/client/nav-link-store.d.ts.map +0 -1
- package/dist/dev-tools/warnings.d.ts +0 -109
- package/dist/dev-tools/warnings.d.ts.map +0 -1
- package/dist/server/flush.d.ts +0 -74
- package/dist/server/flush.d.ts.map +0 -1
- package/src/client/link-pending-store.ts +0 -116
- package/src/client/nav-link-store.ts +0 -37
- package/src/dev-tools/warnings.ts +0 -238
- package/src/server/flush.ts +0 -183
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import type { CacheHandler, CacheOptions } from './index';
|
|
2
2
|
import { stableStringify } from './stable-stringify';
|
|
3
3
|
import { createSingleflight } from './singleflight';
|
|
4
|
+
import {
|
|
5
|
+
currentInvalidationEpoch,
|
|
6
|
+
lastInvalidationEpochFor,
|
|
7
|
+
wasInvalidatedSince,
|
|
8
|
+
} from './invalidation-epoch';
|
|
4
9
|
import { addSpanEventSync } from '../server/tracing.js';
|
|
5
10
|
import { logSwrRefetchFailed } from '../server/logger.js';
|
|
6
11
|
import { getWaitUntil } from '../server/waituntil-bridge.js';
|
|
7
12
|
import { fnv1aHash } from './fast-hash.js';
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
let defaultSingleflight = createSingleflight();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Set the timeout for the module-level default singleflight.
|
|
18
|
+
* Called at framework boot with renderTimeoutMs from timber.config.ts so that
|
|
19
|
+
* cache calls without a per-call timeoutMs still get deadlock protection.
|
|
20
|
+
* See design/06-caching.md §"Singleflight Timeout — Deadlock Risk".
|
|
21
|
+
*/
|
|
22
|
+
export function setDefaultSingleflightTimeout(timeoutMs: number): void {
|
|
23
|
+
defaultSingleflight = createSingleflight({ timeoutMs });
|
|
24
|
+
}
|
|
10
25
|
|
|
11
26
|
/**
|
|
12
27
|
* Generate a cache key from function identity and serialized args.
|
|
@@ -35,16 +50,59 @@ function resolveTags<Fn extends (...args: any[]) => any>(
|
|
|
35
50
|
return opts.tags(...args);
|
|
36
51
|
}
|
|
37
52
|
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
/** Per-process count of byte-identical fn sources seen by deriveFallbackFnId. */
|
|
54
|
+
const fallbackIdOccurrences = new Map<string, number>();
|
|
55
|
+
let hasWarnedFallbackId = false;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Derive a fallback fnId for callsites the timber-cache-transform Vite plugin
|
|
59
|
+
* could not statically detect (aliased bindings, user re-export wrappers).
|
|
60
|
+
*
|
|
61
|
+
* The base is fnv1a(fn.toString()) — deterministic across instances and cold
|
|
62
|
+
* starts of the same build, so two *different* functions can never swap fnIds
|
|
63
|
+
* by module-evaluation order (the TIM-997 poisoning scenario that the old
|
|
64
|
+
* process-local counter allowed; see TIM-1054). A per-process occurrence
|
|
65
|
+
* suffix disambiguates byte-identical duplicate sources. That residue is the
|
|
66
|
+
* one remaining hazard: closures produced by the same factory share source
|
|
67
|
+
* text but capture different values, and their occurrence order may differ
|
|
68
|
+
* across instances — such callsites need an explicit `key` option.
|
|
69
|
+
*/
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
+
function deriveFallbackFnId(fn: (...args: any[]) => any): string {
|
|
72
|
+
const sourceHash = fnv1aHash(fn.toString());
|
|
73
|
+
const occurrence = fallbackIdOccurrences.get(sourceHash) ?? 0;
|
|
74
|
+
fallbackIdOccurrences.set(sourceHash, occurrence + 1);
|
|
75
|
+
|
|
76
|
+
if (!hasWarnedFallbackId && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'test') {
|
|
77
|
+
hasWarnedFallbackId = true;
|
|
78
|
+
console.warn(
|
|
79
|
+
'[timber] cache() callsite has no build-time stable ID and no explicit `key` option — ' +
|
|
80
|
+
'the call is not statically visible to the timber Vite plugin (e.g. an aliased ' +
|
|
81
|
+
'binding like `const c = cache` or a re-export wrapper module). Falling back to a ' +
|
|
82
|
+
'function-source hash for the cache key. With a shared CacheHandler (Redis/KV) this ' +
|
|
83
|
+
'is safe for distinct functions, but byte-identical closures from a factory can swap ' +
|
|
84
|
+
'cache entries across instances. Provide an explicit `key` option to make the cache ' +
|
|
85
|
+
'key contract stable and auditable.'
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return `timber-cache:fn:${sourceHash}:${occurrence}`;
|
|
90
|
+
}
|
|
40
91
|
|
|
41
92
|
/**
|
|
42
93
|
* Creates a cached wrapper around an async function.
|
|
43
94
|
*
|
|
44
|
-
* -
|
|
45
|
-
* - Singleflight: concurrent misses → single execution
|
|
95
|
+
* - FNV-1a default keys with normalized JSON args
|
|
96
|
+
* - Singleflight: concurrent misses → single execution AND single
|
|
97
|
+
* handler.set — the write happens inside the coalesced callback (TIM-1028)
|
|
98
|
+
* - Writes are skipped when the key/tags were invalidated while fn was in
|
|
99
|
+
* flight, so explicit invalidation is never silently undone (TIM-1028)
|
|
46
100
|
* - SWR: serve stale immediately, background refetch
|
|
47
101
|
* - Tags as string[] or function of args
|
|
102
|
+
* - The handler is resolved per invocation via `getHandler`, never captured
|
|
103
|
+
* at wrapper-creation time — module-scope cache() wrappers created before
|
|
104
|
+
* the framework's boot wiring calls setCacheHandler() still use the
|
|
105
|
+
* configured handler once it arrives (TIM-1029)
|
|
48
106
|
* - No ALS dependency for correctness — cache() works outside a request
|
|
49
107
|
* context. The waitUntil ALS bridge is read optionally so the SWR
|
|
50
108
|
* background refetch survives handler return on Workers/Lambda.
|
|
@@ -56,44 +114,30 @@ let hasWarnedUnstableId = false;
|
|
|
56
114
|
export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
57
115
|
fn: Fn,
|
|
58
116
|
opts: CacheOptions<Fn>,
|
|
59
|
-
|
|
117
|
+
getHandler: () => CacheHandler,
|
|
60
118
|
stableId?: string
|
|
61
119
|
): Fn {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
typeof process !== 'undefined' &&
|
|
74
|
-
process.env?.NODE_ENV !== 'test'
|
|
75
|
-
) {
|
|
76
|
-
hasWarnedUnstableId = true;
|
|
77
|
-
console.warn(
|
|
78
|
-
'[timber] cache() called without a stable ID or explicit key function. ' +
|
|
79
|
-
'Cache keys will use a process-local counter which is unstable across ' +
|
|
80
|
-
'cold starts. Run through the timber Vite plugin or provide an explicit ' +
|
|
81
|
-
'`key` option to fix this.'
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
const sf = opts.timeoutMs
|
|
86
|
-
? createSingleflight({ timeoutMs: opts.timeoutMs })
|
|
87
|
-
: defaultSingleflight;
|
|
120
|
+
// With an explicit `key` option the fnId never reaches key generation, so
|
|
121
|
+
// no fallback derivation (or warning) is needed in that case.
|
|
122
|
+
const fnId = stableId ?? (opts.key ? undefined : deriveFallbackFnId(fn));
|
|
123
|
+
|
|
124
|
+
// Per-call timeoutMs gets a dedicated singleflight instance. Otherwise
|
|
125
|
+
// resolve defaultSingleflight per invocation (not captured at creation
|
|
126
|
+
// time) so wrappers created before boot pick up the timeout once
|
|
127
|
+
// setDefaultSingleflightTimeout is called (TIM-1032, same rationale as
|
|
128
|
+
// getCacheHandler for TIM-1029).
|
|
129
|
+
const perCallSf =
|
|
130
|
+
opts.timeoutMs !== undefined ? createSingleflight({ timeoutMs: opts.timeoutMs }) : undefined;
|
|
88
131
|
|
|
89
132
|
// Cast to Fn to preserve the original function's generic call signature.
|
|
90
133
|
// Without this, generic type parameters (e.g. <T> in apiFetch<T>) are
|
|
91
134
|
// erased and callers lose type safety on the return type.
|
|
92
135
|
return (async (...args: Parameters<Fn>): Promise<Awaited<ReturnType<Fn>>> => {
|
|
93
|
-
const
|
|
136
|
+
const sf = perCallSf ?? defaultSingleflight;
|
|
137
|
+
const key = opts.key ? opts.key(...args) : defaultKeyGenerator(fnId as string, args);
|
|
94
138
|
|
|
95
139
|
const cacheStart = performance.now();
|
|
96
|
-
const cached = await
|
|
140
|
+
const cached = await getHandler().get(key);
|
|
97
141
|
|
|
98
142
|
if (cached && !cached.stale) {
|
|
99
143
|
// Record as OTEL span event on enclosing span (not a child span).
|
|
@@ -105,6 +149,30 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
105
149
|
return cached.value as Awaited<ReturnType<Fn>>;
|
|
106
150
|
}
|
|
107
151
|
|
|
152
|
+
// From here fn() will execute (SWR refetch or miss). Scope the
|
|
153
|
+
// singleflight key by the last invalidation epoch affecting this
|
|
154
|
+
// key/tags: a caller arriving after an invalidation must not coalesce
|
|
155
|
+
// onto a flight that started before it — it would receive the
|
|
156
|
+
// pre-invalidation result the invalidation was issued to discard.
|
|
157
|
+
// NUL separator: cannot collide with user-provided key strings.
|
|
158
|
+
const tags = resolveTags(opts, args);
|
|
159
|
+
const flightKey = `${key}\u0000${lastInvalidationEpochFor(key, tags)}`;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Run fn and store the result — exactly once per coalesced execution,
|
|
163
|
+
* inside the singleflight callback. The write is skipped when the key or
|
|
164
|
+
* any resolved tag was invalidated after fn started: storing would
|
|
165
|
+
* resurrect just-invalidated data as fresh for the full TTL.
|
|
166
|
+
*/
|
|
167
|
+
const executeAndStore = async (): Promise<Awaited<ReturnType<Fn>>> => {
|
|
168
|
+
const startEpoch = currentInvalidationEpoch();
|
|
169
|
+
const result = await fn(...args);
|
|
170
|
+
if (!wasInvalidatedSince(startEpoch, key, tags)) {
|
|
171
|
+
await getHandler().set(key, result, { ttl: opts.ttl, tags });
|
|
172
|
+
}
|
|
173
|
+
return result;
|
|
174
|
+
};
|
|
175
|
+
|
|
108
176
|
if (cached && cached.stale && opts.staleWhileRevalidate) {
|
|
109
177
|
// Record stale cache hit as OTEL span event (fire-and-forget).
|
|
110
178
|
addSpanEventSync('timber.cache.hit', {
|
|
@@ -114,11 +182,9 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
114
182
|
});
|
|
115
183
|
// Serve stale immediately, trigger background refetch
|
|
116
184
|
const refetch = sf
|
|
117
|
-
.do(`swr:${
|
|
185
|
+
.do(`swr:${flightKey}`, async () => {
|
|
118
186
|
try {
|
|
119
|
-
|
|
120
|
-
const tags = resolveTags(opts, args);
|
|
121
|
-
await handler.set(key, fresh, { ttl: opts.ttl, tags });
|
|
187
|
+
await executeAndStore();
|
|
122
188
|
} catch (err) {
|
|
123
189
|
// Failed refetch — stale entry continues to be served.
|
|
124
190
|
logSwrRefetchFailed({ cacheKey: key, error: err });
|
|
@@ -137,10 +203,10 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
137
203
|
return cached.value as Awaited<ReturnType<Fn>>;
|
|
138
204
|
}
|
|
139
205
|
|
|
140
|
-
// Cache miss (or stale without SWR) — execute with singleflight
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
206
|
+
// Cache miss (or stale without SWR) — execute with singleflight.
|
|
207
|
+
// The handler.set lives inside the coalesced callback, so N concurrent
|
|
208
|
+
// misses produce exactly one write (TIM-1028).
|
|
209
|
+
const result = await sf.do(flightKey, executeAndStore);
|
|
144
210
|
|
|
145
211
|
// Record cache miss as OTEL span event (fire-and-forget).
|
|
146
212
|
addSpanEventSync('timber.cache.miss', {
|
|
@@ -148,16 +214,6 @@ export function createCache<Fn extends (...args: any[]) => Promise<any>>(
|
|
|
148
214
|
duration_ms: Math.round(performance.now() - cacheStart),
|
|
149
215
|
});
|
|
150
216
|
|
|
151
|
-
return result
|
|
217
|
+
return result;
|
|
152
218
|
}) as unknown as Fn;
|
|
153
219
|
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Invalidate cache entries by tag or key.
|
|
157
|
-
*/
|
|
158
|
-
createCache.invalidate = async function invalidate(
|
|
159
|
-
handler: CacheHandler,
|
|
160
|
-
opts: { key?: string; tag?: string }
|
|
161
|
-
): Promise<void> {
|
|
162
|
-
await handler.invalidate(opts);
|
|
163
|
-
};
|
package/src/cli-schema-sync.ts
CHANGED
|
@@ -36,16 +36,113 @@ function walkSegments(node: SegmentNode, segments: Set<string>): void {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Replace comment bodies (and optionally string contents) with spaces,
|
|
41
|
+
* preserving length and newlines so every index maps 1:1 to the original.
|
|
42
|
+
* Lets the structural scanning below ignore `segmentParams` mentions and
|
|
43
|
+
* braces inside comments/strings without a TS parser (fine pre-alpha,
|
|
44
|
+
* see TIM-1066).
|
|
45
|
+
*
|
|
46
|
+
* With `maskStrings`, string contents are blanked too — except literal
|
|
47
|
+
* 'segmentParams' strings, kept so a quoted property key stays findable.
|
|
48
|
+
*/
|
|
49
|
+
function maskSource(content: string, maskStrings: boolean): string {
|
|
50
|
+
const out = content.split('');
|
|
51
|
+
const n = content.length;
|
|
52
|
+
let i = 0;
|
|
53
|
+
|
|
54
|
+
const blank = (from: number, to: number): void => {
|
|
55
|
+
for (let j = from; j < to; j++) {
|
|
56
|
+
if (content[j] !== '\n') out[j] = ' ';
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
while (i < n) {
|
|
61
|
+
const ch = content[i];
|
|
62
|
+
const next = content[i + 1];
|
|
63
|
+
if (ch === '/' && next === '/') {
|
|
64
|
+
const start = i;
|
|
65
|
+
while (i < n && content[i] !== '\n') i++;
|
|
66
|
+
blank(start, i);
|
|
67
|
+
} else if (ch === '/' && next === '*') {
|
|
68
|
+
const start = i;
|
|
69
|
+
i += 2;
|
|
70
|
+
while (i < n && !(content[i] === '*' && content[i + 1] === '/')) i++;
|
|
71
|
+
i = Math.min(i + 2, n);
|
|
72
|
+
blank(start, i);
|
|
73
|
+
} else if (ch === '"' || ch === "'" || ch === '`') {
|
|
74
|
+
const start = i;
|
|
75
|
+
i++;
|
|
76
|
+
while (i < n && content[i] !== ch) {
|
|
77
|
+
if (content[i] === '\\') i++;
|
|
78
|
+
i++;
|
|
79
|
+
}
|
|
80
|
+
const end = i; // at closing quote (or n if unterminated)
|
|
81
|
+
if (maskStrings && content.slice(start + 1, end) !== 'segmentParams') {
|
|
82
|
+
blank(start + 1, end);
|
|
83
|
+
}
|
|
84
|
+
i++;
|
|
85
|
+
} else {
|
|
86
|
+
i++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out.join('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Locate the `segmentParams: { ... }` block in a schema file.
|
|
94
|
+
* Returns the indices of the opening and matching closing brace, or null
|
|
95
|
+
* if no segmentParams property is found. Comments and strings are masked
|
|
96
|
+
* first, so a JSDoc example mentioning `segmentParams: { ... }` above the
|
|
97
|
+
* real defineSchema call is not mistaken for the block, and braces inside
|
|
98
|
+
* comments/strings don't skew the depth count. The search is anchored to
|
|
99
|
+
* the `defineSchema(` call when present, so helper objects with their own
|
|
100
|
+
* segmentParams property earlier in the file are skipped.
|
|
101
|
+
*/
|
|
102
|
+
export function findSegmentParamsBlock(
|
|
103
|
+
content: string
|
|
104
|
+
): { openBrace: number; closeBrace: number } | null {
|
|
105
|
+
const masked = maskSource(content, true);
|
|
106
|
+
|
|
107
|
+
const defineCall = /\bdefineSchema\s*\(/.exec(masked);
|
|
108
|
+
const keyRe = /(['"]?)segmentParams\1\s*:\s*\{/g;
|
|
109
|
+
keyRe.lastIndex = defineCall ? defineCall.index + defineCall[0].length : 0;
|
|
110
|
+
const keyMatch = keyRe.exec(masked);
|
|
111
|
+
if (!keyMatch) return null;
|
|
112
|
+
|
|
113
|
+
const openBrace = keyMatch.index + keyMatch[0].length - 1;
|
|
114
|
+
let depth = 1;
|
|
115
|
+
let i = openBrace + 1;
|
|
116
|
+
while (i < masked.length && depth > 0) {
|
|
117
|
+
if (masked[i] === '{') depth++;
|
|
118
|
+
else if (masked[i] === '}') depth--;
|
|
119
|
+
i++;
|
|
120
|
+
}
|
|
121
|
+
if (depth > 0) return null;
|
|
122
|
+
// i now points to just after the closing brace
|
|
123
|
+
return { openBrace, closeBrace: i - 1 };
|
|
124
|
+
}
|
|
125
|
+
|
|
39
126
|
/**
|
|
40
127
|
* Parse existing bracket keys from an app/schema.ts file.
|
|
41
|
-
*
|
|
128
|
+
* Scoped to the segmentParams: { ... } block — bracket keys elsewhere in
|
|
129
|
+
* the file (unrelated objects, comments above defineSchema) are ignored.
|
|
130
|
+
* Comments inside the block are masked, so a commented-out entry does not
|
|
131
|
+
* count as registered.
|
|
132
|
+
*
|
|
133
|
+
* The key charset matches the route classifier (segment-classify.ts):
|
|
134
|
+
* any character except `]`, so hyphenated params like '[my-param]' parse.
|
|
42
135
|
*/
|
|
43
136
|
export function parseExistingSchemaKeys(content: string): string[] {
|
|
137
|
+
const block = findSegmentParamsBlock(content);
|
|
138
|
+
if (!block) return [];
|
|
139
|
+
|
|
140
|
+
const blockContent = maskSource(content, false).slice(block.openBrace, block.closeBrace);
|
|
44
141
|
const keys: string[] = [];
|
|
45
142
|
// Match quoted bracket keys like '[artistSlug]', '[...slug]', '[[...topic]]'
|
|
46
|
-
const re = /['"](\[{1,2}(?:\.{3})
|
|
143
|
+
const re = /['"](\[{1,2}(?:\.{3})?[^\]]+\]{1,2})['"]\s*:/g;
|
|
47
144
|
let m: RegExpExecArray | null;
|
|
48
|
-
while ((m = re.exec(
|
|
145
|
+
while ((m = re.exec(blockContent)) !== null) {
|
|
49
146
|
keys.push(m[1]);
|
|
50
147
|
}
|
|
51
148
|
return keys;
|
|
@@ -121,31 +218,14 @@ function ensureCodecImport(content: string): string {
|
|
|
121
218
|
function appendToSchema(content: string, newSegments: string[]): string {
|
|
122
219
|
content = ensureCodecImport(content);
|
|
123
220
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (spIndex === -1) {
|
|
221
|
+
const block = findSegmentParamsBlock(content);
|
|
222
|
+
if (!block) {
|
|
127
223
|
throw new Error(
|
|
128
224
|
'[timber] Could not find segmentParams in schema file. ' +
|
|
129
225
|
'Expected a defineSchema({ segmentParams: { ... } }) call.'
|
|
130
226
|
);
|
|
131
227
|
}
|
|
132
|
-
|
|
133
|
-
// Find the opening brace after segmentParams
|
|
134
|
-
const openBrace = content.indexOf('{', spIndex + 'segmentParams'.length);
|
|
135
|
-
if (openBrace === -1) {
|
|
136
|
-
throw new Error('[timber] Could not find opening brace for segmentParams.');
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Count braces to find the matching close
|
|
140
|
-
let depth = 1;
|
|
141
|
-
let i = openBrace + 1;
|
|
142
|
-
while (i < content.length && depth > 0) {
|
|
143
|
-
if (content[i] === '{') depth++;
|
|
144
|
-
else if (content[i] === '}') depth--;
|
|
145
|
-
i++;
|
|
146
|
-
}
|
|
147
|
-
// i now points to just after the closing brace
|
|
148
|
-
const closeBrace = i - 1;
|
|
228
|
+
const { openBrace, closeBrace } = block;
|
|
149
229
|
|
|
150
230
|
// Detect indentation from existing entries
|
|
151
231
|
const existingLine = content.slice(openBrace, closeBrace).match(/^(\s+)['"]\[/m);
|
|
@@ -168,21 +248,32 @@ function appendToSchema(content: string, newSegments: string[]): string {
|
|
|
168
248
|
|
|
169
249
|
/**
|
|
170
250
|
* Remove entries from the segmentParams block whose keys match `staleSegments`.
|
|
171
|
-
* Removes the entire
|
|
251
|
+
* Removes the entire entry including trailing comma/whitespace. Scoped to
|
|
252
|
+
* the segmentParams block — identical keys in unrelated objects elsewhere
|
|
253
|
+
* in the file are left untouched.
|
|
172
254
|
*/
|
|
173
255
|
function removeFromSchema(content: string, staleSegments: string[]): string {
|
|
256
|
+
const block = findSegmentParamsBlock(content);
|
|
257
|
+
if (!block) return content;
|
|
258
|
+
|
|
259
|
+
let blockContent = content.slice(block.openBrace, block.closeBrace);
|
|
260
|
+
// Key matching and the entry walk run on a comment-masked copy (same
|
|
261
|
+
// length, indices align) so commented-out entries are never removed.
|
|
262
|
+
// Both copies are spliced in sync to keep indices valid.
|
|
263
|
+
let blockMasked = maskSource(blockContent, false);
|
|
264
|
+
|
|
174
265
|
for (const seg of staleSegments) {
|
|
175
266
|
const escaped = seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
176
267
|
const keyRe = new RegExp(`^[ \t]*['"]${escaped}['"][ \t]*:`, 'gm');
|
|
177
268
|
let m: RegExpExecArray | null;
|
|
178
|
-
while ((m = keyRe.exec(
|
|
269
|
+
while ((m = keyRe.exec(blockMasked)) !== null) {
|
|
179
270
|
const start = m.index;
|
|
180
271
|
let i = m.index + m[0].length;
|
|
181
272
|
let depth = 0;
|
|
182
273
|
let inString: string | null = null;
|
|
183
274
|
|
|
184
|
-
while (i <
|
|
185
|
-
const ch =
|
|
275
|
+
while (i < blockMasked.length) {
|
|
276
|
+
const ch = blockMasked[i];
|
|
186
277
|
if (inString) {
|
|
187
278
|
if (ch === '\\') {
|
|
188
279
|
i++;
|
|
@@ -205,13 +296,15 @@ function removeFromSchema(content: string, staleSegments: string[]): string {
|
|
|
205
296
|
}
|
|
206
297
|
|
|
207
298
|
// Consume trailing newline
|
|
208
|
-
if (i <
|
|
299
|
+
if (i < blockMasked.length && blockMasked[i] === '\n') i++;
|
|
209
300
|
|
|
210
|
-
|
|
301
|
+
blockContent = blockContent.slice(0, start) + blockContent.slice(i);
|
|
302
|
+
blockMasked = blockMasked.slice(0, start) + blockMasked.slice(i);
|
|
211
303
|
keyRe.lastIndex = start;
|
|
212
304
|
}
|
|
213
305
|
}
|
|
214
|
-
|
|
306
|
+
|
|
307
|
+
return content.slice(0, block.openBrace) + blockContent + content.slice(block.closeBrace);
|
|
215
308
|
}
|
|
216
309
|
|
|
217
310
|
export interface SchemaSyncResult {
|
package/src/cli.ts
CHANGED
|
@@ -17,6 +17,8 @@ type Command = (typeof COMMANDS)[number];
|
|
|
17
17
|
export interface ParsedArgs {
|
|
18
18
|
command: Command;
|
|
19
19
|
config: string | undefined;
|
|
20
|
+
/** Positional arguments after the command (used by `timber schema sync`). */
|
|
21
|
+
rest: string[];
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface CommandOptions {
|
|
@@ -26,6 +28,11 @@ export interface CommandOptions {
|
|
|
26
28
|
/**
|
|
27
29
|
* Parse CLI arguments into a structured command + options.
|
|
28
30
|
* Accepts: timber <command> [--config|-c <path>]
|
|
31
|
+
*
|
|
32
|
+
* Unknown flags are an error, not silently ignored — `timber dev --port 4000`
|
|
33
|
+
* starting on the default port would contradict fail-loudly. The CLI surface
|
|
34
|
+
* is intentionally --config-only (design/11-platform.md §CLI); port selection
|
|
35
|
+
* goes through the PORT env var or server.port in vite.config.ts.
|
|
29
36
|
*/
|
|
30
37
|
export function parseArgs(args: string[]): ParsedArgs {
|
|
31
38
|
if (args.length === 0) {
|
|
@@ -40,16 +47,31 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
let config: string | undefined;
|
|
50
|
+
const rest: string[] = [];
|
|
43
51
|
for (let i = 1; i < args.length; i++) {
|
|
44
|
-
|
|
52
|
+
const arg = args[i];
|
|
53
|
+
if (arg === '--config' || arg === '-c') {
|
|
45
54
|
config = args[++i];
|
|
46
55
|
if (!config) {
|
|
47
56
|
throw new Error('--config requires a path argument');
|
|
48
57
|
}
|
|
58
|
+
} else if (arg.startsWith('-')) {
|
|
59
|
+
const portHint =
|
|
60
|
+
arg === '--port' || arg === '-p'
|
|
61
|
+
? ' To change the port, set the PORT environment variable (PORT=4000 timber dev) ' +
|
|
62
|
+
'or server.port in vite.config.ts.'
|
|
63
|
+
: '';
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Unknown option: ${arg}. The timber CLI only accepts --config <path>.${portHint}`
|
|
66
|
+
);
|
|
67
|
+
} else if (command === 'schema') {
|
|
68
|
+
rest.push(arg);
|
|
69
|
+
} else {
|
|
70
|
+
throw new Error(`Unexpected argument: ${arg}. Usage: timber ${command} [--config <path>]`);
|
|
49
71
|
}
|
|
50
72
|
}
|
|
51
73
|
|
|
52
|
-
return { command: command as Command, config };
|
|
74
|
+
return { command: command as Command, config, rest };
|
|
53
75
|
}
|
|
54
76
|
|
|
55
77
|
// ─── Command Implementations ─────────────────────────────────────────────────
|
|
@@ -105,51 +127,25 @@ export function resolvePreviewStrategy(
|
|
|
105
127
|
return 'vite';
|
|
106
128
|
}
|
|
107
129
|
|
|
108
|
-
/**
|
|
109
|
-
* Load timber.config.ts from the project root.
|
|
110
|
-
* Returns the config object with adapter, output, etc.
|
|
111
|
-
* Returns null if no config file is found.
|
|
112
|
-
*/
|
|
113
|
-
async function loadTimberConfig(root: string): Promise<{
|
|
114
|
-
adapter?: import('./adapters/types').TimberPlatformAdapter;
|
|
115
|
-
output?: string;
|
|
116
|
-
buildDir?: string;
|
|
117
|
-
} | null> {
|
|
118
|
-
const { existsSync } = await import('node:fs');
|
|
119
|
-
const { join } = await import('node:path');
|
|
120
|
-
const { pathToFileURL } = await import('node:url');
|
|
121
|
-
|
|
122
|
-
const configNames = ['timber.config.ts', 'timber.config.js', 'timber.config.mjs'];
|
|
123
|
-
|
|
124
|
-
for (const name of configNames) {
|
|
125
|
-
const configPath = join(root, name);
|
|
126
|
-
if (existsSync(configPath)) {
|
|
127
|
-
// Use Vite's built-in config loading to handle TypeScript
|
|
128
|
-
const mod = await import(pathToFileURL(configPath).href);
|
|
129
|
-
return mod.default ?? mod;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
130
|
/**
|
|
136
131
|
* Serve the production build for local testing.
|
|
137
132
|
* If the adapter provides a preview() method, it takes priority.
|
|
138
133
|
* Otherwise falls back to Vite's built-in preview server.
|
|
134
|
+
*
|
|
135
|
+
* Uses the same loadTimberConfigFile loader as dev/build — one loader,
|
|
136
|
+
* one set of module semantics. A config file that exists but fails to
|
|
137
|
+
* load throws (naming the file) instead of silently degrading to plain
|
|
138
|
+
* Vite preview. See TIM-1066.
|
|
139
139
|
*/
|
|
140
140
|
export async function runPreview(options: CommandOptions, _deps?: ViteDeps): Promise<void> {
|
|
141
|
-
const {
|
|
141
|
+
const { loadTimberConfigFile, resolveBuildDir } = await import('./plugin-context.js');
|
|
142
142
|
|
|
143
|
-
// Try to load timber config for adapter-specific preview
|
|
144
143
|
const root = process.cwd();
|
|
145
|
-
const config =
|
|
144
|
+
const config = loadTimberConfigFile(root);
|
|
146
145
|
const adapter = config?.adapter as import('./adapters/types').TimberPlatformAdapter | undefined;
|
|
147
146
|
|
|
148
147
|
if (resolvePreviewStrategy(adapter) === 'adapter') {
|
|
149
|
-
|
|
150
|
-
const buildDir = config?.buildDir
|
|
151
|
-
? resolve(root, config.buildDir)
|
|
152
|
-
: join(root, '.timber', 'dist');
|
|
148
|
+
const buildDir = resolveBuildDir(root, config?.buildDir);
|
|
153
149
|
const timberConfig = { output: (config?.output ?? 'server') as 'server' | 'static' };
|
|
154
150
|
await adapter!.preview!(timberConfig, buildDir);
|
|
155
151
|
return;
|
|
@@ -259,7 +255,7 @@ async function main(): Promise<void> {
|
|
|
259
255
|
await runCheck(options);
|
|
260
256
|
break;
|
|
261
257
|
case 'schema':
|
|
262
|
-
await runSchema(
|
|
258
|
+
await runSchema(parsed.rest);
|
|
263
259
|
break;
|
|
264
260
|
}
|
|
265
261
|
}
|
|
@@ -57,17 +57,6 @@ export function setupHmr(router: RouterInstance): void {
|
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
// Listen for dev warnings forwarded from the server via WebSocket.
|
|
61
|
-
// See dev-warnings.ts — emitOnce() sends these via server.hot.send().
|
|
62
|
-
hot.on('timber:dev-warning', (data: unknown) => {
|
|
63
|
-
const warning = data as { level: string; message: string };
|
|
64
|
-
if (warning.level === 'error') {
|
|
65
|
-
console.error(warning.message);
|
|
66
|
-
} else {
|
|
67
|
-
console.warn(warning.message);
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
|
|
71
60
|
// Listen for server console logs forwarded via WebSocket.
|
|
72
61
|
// Replays them in the browser console with a [SERVER] prefix
|
|
73
62
|
// so developers can see server output without switching to the terminal.
|
|
@@ -33,6 +33,44 @@ import type { TopLoaderConfig } from '../top-loader.js';
|
|
|
33
33
|
|
|
34
34
|
import type { RscStreamResult } from './rsc-stream.js';
|
|
35
35
|
|
|
36
|
+
let showHydrationError: ((error: unknown) => void) | null = null;
|
|
37
|
+
let isHydrationError: ((error: unknown) => boolean) | null = null;
|
|
38
|
+
let pendingHydrationErrors: unknown[] = [];
|
|
39
|
+
|
|
40
|
+
// React 19 emits attribute-only hydration mismatches via console.error
|
|
41
|
+
// with format substitution: console.error(fmt, link, diff). We must
|
|
42
|
+
// intercept before hydrateRoot() to catch errors during initial hydration.
|
|
43
|
+
const ATTR_MISMATCH_RE =
|
|
44
|
+
/A tree hydrated but some attributes of the server rendered HTML didn't match/;
|
|
45
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
46
|
+
const origConsoleError = console.error;
|
|
47
|
+
console.error = (...args: unknown[]) => {
|
|
48
|
+
origConsoleError.apply(console, args);
|
|
49
|
+
if (typeof args[0] === 'string' && ATTR_MISMATCH_RE.test(args[0])) {
|
|
50
|
+
const message = args
|
|
51
|
+
.slice(1)
|
|
52
|
+
.reduce<string>((s, arg) => s.replace('%s', String(arg)), args[0] as string);
|
|
53
|
+
queueOrShowHydrationError(new Error(message));
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
import('../../dev-tools/hydration-overlay.js').then((m) => {
|
|
57
|
+
showHydrationError = m.showHydrationError;
|
|
58
|
+
isHydrationError = m.isHydrationError;
|
|
59
|
+
for (const err of pendingHydrationErrors) {
|
|
60
|
+
if (m.isHydrationError(err)) m.showHydrationError(err);
|
|
61
|
+
}
|
|
62
|
+
pendingHydrationErrors = [];
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function queueOrShowHydrationError(error: unknown): void {
|
|
67
|
+
if (showHydrationError && isHydrationError) {
|
|
68
|
+
if (isHydrationError(error)) showHydrationError(error);
|
|
69
|
+
} else {
|
|
70
|
+
pendingHydrationErrors.push(error);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
36
74
|
interface HydrateOptions {
|
|
37
75
|
/** RSC stream result (null if no inlined payload) */
|
|
38
76
|
rscResult: RscStreamResult | null;
|
|
@@ -116,13 +154,9 @@ export function hydrateApp({ rscResult, config }: HydrateOptions): void {
|
|
|
116
154
|
// the shell is flushed). React replays the error during hydration
|
|
117
155
|
// but the server HTML is already correct — no recovery needed.
|
|
118
156
|
onRecoverableError(error: unknown) {
|
|
119
|
-
// Suppress errors during page unload (refresh/navigate away).
|
|
120
|
-
// The aborted stream causes incomplete HTML which React flags
|
|
121
|
-
// as a recoverable error — but the page is being replaced.
|
|
122
157
|
if (isPageUnloading()) return;
|
|
123
|
-
// Only log in dev — in production these are expected for
|
|
124
|
-
// deny() inside Suspense and streaming error boundaries.
|
|
125
158
|
if (process.env.NODE_ENV === 'development') {
|
|
159
|
+
queueOrShowHydrationError(error);
|
|
126
160
|
console.debug('[timber] Hydration recoverable error:', error);
|
|
127
161
|
}
|
|
128
162
|
},
|