@mandujs/core 0.29.1 → 0.31.0
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/package.json +5 -1
- package/src/bundler/analyzer.ts +843 -0
- package/src/client/index.ts +11 -1
- package/src/client/rpc.ts +293 -140
- package/src/config/mandu.ts +117 -0
- package/src/config/validate.ts +92 -0
- package/src/contract/index.ts +18 -0
- package/src/contract/rpc.ts +443 -0
- package/src/filling/context.ts +166 -0
- package/src/filling/filling.ts +10 -1
- package/src/index.ts +32 -0
- package/src/middleware/index.ts +7 -0
- package/src/middleware/scheduler-cron.ts +96 -0
- package/src/observability/index.ts +26 -0
- package/src/observability/tracing.ts +694 -0
- package/src/runtime/cache.ts +197 -13
- package/src/runtime/index.ts +8 -0
- package/src/runtime/server.ts +542 -24
- package/src/scheduler/index.ts +547 -343
- package/src/scheduler/validate.ts +169 -0
package/src/runtime/server.ts
CHANGED
|
@@ -22,12 +22,17 @@ import {
|
|
|
22
22
|
type CacheStore,
|
|
23
23
|
type CacheStoreStats,
|
|
24
24
|
type CacheLookupResult,
|
|
25
|
+
type CacheConfig,
|
|
25
26
|
MemoryCacheStore,
|
|
26
27
|
lookupCache,
|
|
27
28
|
createCacheEntry,
|
|
28
29
|
createCachedResponse,
|
|
30
|
+
computeCacheControl,
|
|
29
31
|
getCacheStoreStats,
|
|
30
32
|
setGlobalCache,
|
|
33
|
+
setGlobalCacheDefaults,
|
|
34
|
+
getGlobalCacheDefaults,
|
|
35
|
+
createCacheStoreFromConfig,
|
|
31
36
|
} from "./cache";
|
|
32
37
|
import {
|
|
33
38
|
createNotFoundResponse,
|
|
@@ -57,6 +62,16 @@ import {
|
|
|
57
62
|
isObservabilityExposed,
|
|
58
63
|
recordHttpRequest,
|
|
59
64
|
} from "../observability/metrics";
|
|
65
|
+
// Phase 18.θ — request tracing. Tracer lifecycle is owned by
|
|
66
|
+
// `startServer()`; `runWithSpan` is used at the absolute TOP of the
|
|
67
|
+
// request handler so every downstream await (middleware, filling
|
|
68
|
+
// loader, SSR render) inherits the active span via AsyncLocalStorage.
|
|
69
|
+
import {
|
|
70
|
+
Tracer,
|
|
71
|
+
createTracerFromConfig,
|
|
72
|
+
runWithSpan,
|
|
73
|
+
setTracer,
|
|
74
|
+
} from "../observability/tracing";
|
|
60
75
|
import {
|
|
61
76
|
type MiddlewareFn,
|
|
62
77
|
type MiddlewareConfig,
|
|
@@ -69,6 +84,11 @@ import {
|
|
|
69
84
|
type ComposedHandler,
|
|
70
85
|
} from "../middleware/compose";
|
|
71
86
|
import type { Middleware } from "../middleware/define";
|
|
87
|
+
// Phase 18.λ — scheduler wiring (statically imported so `startServer` stays
|
|
88
|
+
// synchronous; the cost of unused code is trivial — `defineCron` is a thin
|
|
89
|
+
// wrapper around `Bun.cron`).
|
|
90
|
+
import { defineCron as schedulerDefineCron } from "../scheduler";
|
|
91
|
+
import { setActiveSchedulerRegistration } from "../middleware/scheduler-cron";
|
|
72
92
|
import { createFetchHandler } from "./handler";
|
|
73
93
|
import { wrapBunWebSocket, type WSUpgradeData } from "../filling/ws";
|
|
74
94
|
import { handleImageRequest } from "./image-handler";
|
|
@@ -76,6 +96,14 @@ import { extractShellHtml, createPPRResponse } from "./ppr";
|
|
|
76
96
|
import { isRedirectResponse } from "./redirect";
|
|
77
97
|
import { isNotFoundResponse } from "./not-found";
|
|
78
98
|
import { newId } from "../id";
|
|
99
|
+
// Phase 18.κ — typed RPC dispatch (tRPC-like). See
|
|
100
|
+
// `packages/core/src/contract/rpc.ts` + `docs/architect/typed-rpc.md`.
|
|
101
|
+
import {
|
|
102
|
+
matchRpcPath,
|
|
103
|
+
dispatchRpc,
|
|
104
|
+
registerRpc,
|
|
105
|
+
clearRpcRegistry,
|
|
106
|
+
} from "../contract/rpc";
|
|
79
107
|
import { handleMetadataRoute as dispatchMetadataRoute } from "../routes/metadata-routes";
|
|
80
108
|
import {
|
|
81
109
|
DEFAULT_PRERENDER_DIR,
|
|
@@ -367,12 +395,17 @@ export interface ServerOptions {
|
|
|
367
395
|
*/
|
|
368
396
|
guardConfig?: import("../guard/types").GuardConfig | null;
|
|
369
397
|
/**
|
|
370
|
-
* SSR 캐시 설정 (ISR/SWR 용)
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
* -
|
|
398
|
+
* SSR 캐시 설정 (ISR/SWR 용).
|
|
399
|
+
*
|
|
400
|
+
* Phase 18.ζ — `CacheConfig` 객체를 추가로 지원한다.
|
|
401
|
+
* - `true` : 기본 메모리 캐시 (LRU 1000 엔트리)
|
|
402
|
+
* - `CacheStore` : 커스텀 캐시 구현체 (e.g. redis 어댑터)
|
|
403
|
+
* - `CacheConfig` : `{ defaultMaxAge, defaultSwr, maxEntries, store }`
|
|
404
|
+
* 전역 기본값을 설정 — loader 가 `_cache` 를 안 내도
|
|
405
|
+
* 자동으로 캐싱됨 (Next.js `export const revalidate` 등가).
|
|
406
|
+
* - `false`/undefined : 캐시 비활성화
|
|
374
407
|
*/
|
|
375
|
-
cache?: boolean | CacheStore;
|
|
408
|
+
cache?: boolean | CacheStore | CacheConfig;
|
|
376
409
|
/**
|
|
377
410
|
* Internal management token for local CLI/runtime control endpoints.
|
|
378
411
|
* When set, token-protected endpoints such as `/_mandu/cache` become available.
|
|
@@ -429,6 +462,22 @@ export interface ServerOptions {
|
|
|
429
462
|
observability?: {
|
|
430
463
|
heapEndpoint?: boolean;
|
|
431
464
|
metricsEndpoint?: boolean;
|
|
465
|
+
/**
|
|
466
|
+
* Phase 18.θ — OpenTelemetry-compatible request tracing. See
|
|
467
|
+
* `@mandujs/core/observability` {@link import("../observability/tracing").TracerConfig}
|
|
468
|
+
* for field semantics. `undefined` leaves tracing disabled;
|
|
469
|
+
* `{ enabled: true }` opens a root span for every request and
|
|
470
|
+
* propagates the trace context across `await`s via
|
|
471
|
+
* AsyncLocalStorage. The `MANDU_OTEL_ENDPOINT` env var forces
|
|
472
|
+
* tracing on with the OTLP exporter when the config is omitted.
|
|
473
|
+
*/
|
|
474
|
+
tracing?: {
|
|
475
|
+
enabled?: boolean;
|
|
476
|
+
exporter?: "console" | "otlp";
|
|
477
|
+
endpoint?: string;
|
|
478
|
+
headers?: Record<string, string>;
|
|
479
|
+
serviceName?: string;
|
|
480
|
+
};
|
|
432
481
|
};
|
|
433
482
|
/**
|
|
434
483
|
* Phase 18 — prerendered HTML pass-through (SSG).
|
|
@@ -468,6 +517,37 @@ export interface ServerOptions {
|
|
|
468
517
|
* `secureMiddleware`, `rateLimitMiddleware`).
|
|
469
518
|
*/
|
|
470
519
|
middleware?: Middleware[];
|
|
520
|
+
/**
|
|
521
|
+
* Phase 18.κ — tRPC-like typed RPC endpoints.
|
|
522
|
+
*
|
|
523
|
+
* Keys map to `/api/rpc/<name>/<method>` routes. Each value is a
|
|
524
|
+
* `defineRpc()` result (see `@mandujs/core/contract/rpc`). Populating
|
|
525
|
+
* this field at `startServer()` time registers every endpoint with
|
|
526
|
+
* the global RPC registry; the dispatcher runs BEFORE β's route
|
|
527
|
+
* matcher so RPC routes never collide with file-system API routes.
|
|
528
|
+
*
|
|
529
|
+
* Typically threaded from `ManduConfig.rpc.endpoints`.
|
|
530
|
+
*/
|
|
531
|
+
rpc?: {
|
|
532
|
+
endpoints?: Record<string, import("../contract/rpc").RpcDefinition<import("../contract/rpc").RpcProcedureRecord>>;
|
|
533
|
+
};
|
|
534
|
+
/**
|
|
535
|
+
* Phase 18.λ — declarative cron scheduler.
|
|
536
|
+
*
|
|
537
|
+
* When `jobs` is non-empty and `disabled !== true`, `startServer()`
|
|
538
|
+
* instantiates a `CronRegistration` via `defineCron(jobs)`, calls
|
|
539
|
+
* `.start()` after the HTTP listener is bound, and wires the handle
|
|
540
|
+
* into `stop()` so the returned `ManduServer.stop()` also drains any
|
|
541
|
+
* in-flight cron tick before returning. Jobs whose `runOn` omits
|
|
542
|
+
* `"bun"` are registered but never fire on the local Bun host — they
|
|
543
|
+
* still appear in `status()` so dashboards can render their existence.
|
|
544
|
+
*
|
|
545
|
+
* Typically threaded from `ManduConfig.scheduler`.
|
|
546
|
+
*/
|
|
547
|
+
scheduler?: {
|
|
548
|
+
jobs?: import("../scheduler").CronDef[];
|
|
549
|
+
disabled?: boolean;
|
|
550
|
+
};
|
|
471
551
|
}
|
|
472
552
|
|
|
473
553
|
export interface ManduServer {
|
|
@@ -625,6 +705,12 @@ export interface ServerRegistrySettings {
|
|
|
625
705
|
* as `heapEndpoint`.
|
|
626
706
|
*/
|
|
627
707
|
metricsEndpoint?: boolean;
|
|
708
|
+
/**
|
|
709
|
+
* Phase 18.θ — resolved request-tracing state. `undefined` means
|
|
710
|
+
* tracing is disabled (the hot path is branch-free). When set, every
|
|
711
|
+
* request opens a root span via `tracer.startSpanFromRequest()`.
|
|
712
|
+
*/
|
|
713
|
+
tracer?: import("../observability/tracing").Tracer;
|
|
628
714
|
/**
|
|
629
715
|
* Phase 18 — resolved prerender pass-through state. `undefined`
|
|
630
716
|
* means the feature is disabled for this server instance.
|
|
@@ -691,7 +777,7 @@ export class ServerRegistry {
|
|
|
691
777
|
/** Kitchen dev dashboard handler (dev mode only) */
|
|
692
778
|
kitchen: KitchenHandler | null = null;
|
|
693
779
|
/** 라우트별 캐시 옵션 (filling.loader()의 cacheOptions에서 등록) */
|
|
694
|
-
readonly cacheOptions: Map<string, { revalidate?: number; tags?: string[] }> = new Map();
|
|
780
|
+
readonly cacheOptions: Map<string, { revalidate?: number; staleWhileRevalidate?: number; tags?: string[] }> = new Map();
|
|
695
781
|
/** 라우트별 렌더 모드 */
|
|
696
782
|
readonly renderModes: Map<string, RenderMode> = new Map();
|
|
697
783
|
/** Layout slot 파일 경로 캐시 (모듈 경로 → slot 경로 | null) */
|
|
@@ -1429,6 +1515,73 @@ async function handleRequest(req: Request, router: Router, registry: ServerRegis
|
|
|
1429
1515
|
const requestStart = Date.now();
|
|
1430
1516
|
// Phase 1-4: Correlation ID — 한 요청에서 발생하는 모든 이벤트를 추적
|
|
1431
1517
|
const correlationId = req.headers.get("x-mandu-request-id") ?? newId();
|
|
1518
|
+
|
|
1519
|
+
// ─── Phase 18.θ — TRACING wrap (absolute TOP of request handler) ─────────
|
|
1520
|
+
// Opens a root server span BEFORE γ's prerendered check, BEFORE ζ's
|
|
1521
|
+
// cache check, BEFORE any other Phase 18 logic. The span is bound to
|
|
1522
|
+
// AsyncLocalStorage so every downstream `await` (middleware chain,
|
|
1523
|
+
// filling loader, SSR render, sandbox exec) inherits it transparently
|
|
1524
|
+
// via `getActiveSpan()` / `ctx.span`.
|
|
1525
|
+
//
|
|
1526
|
+
// Parent context: honours an incoming W3C `traceparent` header so
|
|
1527
|
+
// upstream gateways / load balancers can correlate traces across
|
|
1528
|
+
// service boundaries. Missing / malformed header → a new root trace-id.
|
|
1529
|
+
//
|
|
1530
|
+
// Zero overhead when tracing is disabled: `settings.tracer` is
|
|
1531
|
+
// `undefined`, the `if` falls through, and we call the uninstrumented
|
|
1532
|
+
// path exactly as before.
|
|
1533
|
+
const tracer = registry.settings.tracer;
|
|
1534
|
+
if (tracer && tracer.enabled) {
|
|
1535
|
+
const url = new URL(req.url);
|
|
1536
|
+
const rootSpan = tracer.startSpanFromRequest("http.request", req, {
|
|
1537
|
+
kind: "server",
|
|
1538
|
+
attributes: {
|
|
1539
|
+
"http.method": req.method,
|
|
1540
|
+
"http.url": req.url,
|
|
1541
|
+
"http.target": url.pathname,
|
|
1542
|
+
"http.scheme": url.protocol.replace(":", ""),
|
|
1543
|
+
"http.host": url.host,
|
|
1544
|
+
"mandu.correlation_id": correlationId,
|
|
1545
|
+
},
|
|
1546
|
+
});
|
|
1547
|
+
try {
|
|
1548
|
+
const response = await runWithSpan(rootSpan, () =>
|
|
1549
|
+
handleRequestWithTracing(req, router, registry, requestStart, correlationId)
|
|
1550
|
+
);
|
|
1551
|
+
rootSpan.setAttribute("http.status_code", response.status);
|
|
1552
|
+
if (response.status >= 500) {
|
|
1553
|
+
rootSpan.setStatus("error", `HTTP ${response.status}`);
|
|
1554
|
+
} else if (rootSpan.status === "unset") {
|
|
1555
|
+
rootSpan.setStatus("ok");
|
|
1556
|
+
}
|
|
1557
|
+
return response;
|
|
1558
|
+
} catch (err) {
|
|
1559
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1560
|
+
rootSpan.setStatus("error", msg);
|
|
1561
|
+
throw err;
|
|
1562
|
+
} finally {
|
|
1563
|
+
rootSpan.end();
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
// ─── End Phase 18.θ ──────────────────────────────────────────────────────
|
|
1567
|
+
|
|
1568
|
+
return await handleRequestWithTracing(req, router, registry, requestStart, correlationId);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* Phase 18.θ — inner request handler. Extracted from {@link handleRequest}
|
|
1573
|
+
* so the tracing wrap at the top can run the body inside a
|
|
1574
|
+
* `runWithSpan()` scope without a giant indent level. Preserves the
|
|
1575
|
+
* exact pre-tracing semantics (correlation-id logging, Cache-Control
|
|
1576
|
+
* stamping, eventBus emission).
|
|
1577
|
+
*/
|
|
1578
|
+
async function handleRequestWithTracing(
|
|
1579
|
+
req: Request,
|
|
1580
|
+
router: Router,
|
|
1581
|
+
registry: ServerRegistry,
|
|
1582
|
+
requestStart: number,
|
|
1583
|
+
correlationId: string
|
|
1584
|
+
): Promise<Response> {
|
|
1432
1585
|
const result = await handleRequestInternal(req, router, registry);
|
|
1433
1586
|
|
|
1434
1587
|
if (!result.ok) {
|
|
@@ -1629,11 +1782,24 @@ export function redactErrorForBoundary(error: Error, isDev: boolean): { error: E
|
|
|
1629
1782
|
return { error: redacted, digest };
|
|
1630
1783
|
}
|
|
1631
1784
|
|
|
1785
|
+
/**
|
|
1786
|
+
* Phase 18.ζ — per-request 로더 캐시 메타데이터. loader 반환값의 `_cache`
|
|
1787
|
+
* 프로퍼티 또는 `ctx.cache.*` 플루언트 호출에서 수집되어 렌더 후 캐시
|
|
1788
|
+
* 저장 단계에서 route-level defaults 와 merge 된다.
|
|
1789
|
+
*/
|
|
1790
|
+
export interface RuntimeCacheMeta {
|
|
1791
|
+
tags: string[];
|
|
1792
|
+
maxAge?: number;
|
|
1793
|
+
staleWhileRevalidate?: number;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1632
1796
|
interface PageLoadResult {
|
|
1633
1797
|
loaderData: unknown;
|
|
1634
1798
|
cookies?: CookieManager;
|
|
1635
1799
|
/** Layout별 loader 데이터 (모듈 경로 → 데이터) */
|
|
1636
1800
|
layoutData?: Map<string, unknown>;
|
|
1801
|
+
/** Phase 18.ζ — per-request 캐시 메타데이터 (_cache or ctx.cache.*) */
|
|
1802
|
+
cacheMeta?: RuntimeCacheMeta;
|
|
1637
1803
|
/**
|
|
1638
1804
|
* If the page's loader returned or threw a redirect Response, it surfaces
|
|
1639
1805
|
* here. Callers short-circuit SSR and emit this Response to the browser
|
|
@@ -1656,6 +1822,71 @@ interface PageLoadResult {
|
|
|
1656
1822
|
notFound?: Response;
|
|
1657
1823
|
}
|
|
1658
1824
|
|
|
1825
|
+
/**
|
|
1826
|
+
* Phase 18.ζ — loader 반환값에서 `_cache` 블록을 분리한다.
|
|
1827
|
+
*
|
|
1828
|
+
* 반환값이 `{ _cache: {...}, ...rest }` 모양이면 `_cache` 를 추출하고
|
|
1829
|
+
* 나머지를 실제 loader 데이터로 돌려준다. `_cache` 형태가 잘못되면
|
|
1830
|
+
* 조용히 무시하여 런타임이 깨지지 않도록 한다.
|
|
1831
|
+
*/
|
|
1832
|
+
function extractCacheMetaFromReturn(
|
|
1833
|
+
returned: unknown
|
|
1834
|
+
): { data: unknown; meta: RuntimeCacheMeta | null } {
|
|
1835
|
+
if (
|
|
1836
|
+
returned &&
|
|
1837
|
+
typeof returned === "object" &&
|
|
1838
|
+
!Array.isArray(returned) &&
|
|
1839
|
+
"_cache" in (returned as Record<string, unknown>)
|
|
1840
|
+
) {
|
|
1841
|
+
const obj = returned as Record<string, unknown>;
|
|
1842
|
+
const raw = obj._cache as Record<string, unknown> | null | undefined;
|
|
1843
|
+
const data = obj.data ?? (() => {
|
|
1844
|
+
// { _cache, ...rest } → strip _cache from the object
|
|
1845
|
+
const { _cache: _omit, ...rest } = obj as Record<string, unknown>;
|
|
1846
|
+
return rest;
|
|
1847
|
+
})();
|
|
1848
|
+
if (!raw || typeof raw !== "object") {
|
|
1849
|
+
return { data, meta: null };
|
|
1850
|
+
}
|
|
1851
|
+
const tagsRaw = Array.isArray(raw.tags) ? raw.tags : [];
|
|
1852
|
+
const tags = tagsRaw.filter((t): t is string => typeof t === "string" && t.length > 0);
|
|
1853
|
+
const maxAgeRaw = typeof raw.maxAge === "number"
|
|
1854
|
+
? raw.maxAge
|
|
1855
|
+
: typeof raw.revalidate === "number"
|
|
1856
|
+
? raw.revalidate
|
|
1857
|
+
: undefined;
|
|
1858
|
+
const swrRaw = typeof raw.staleWhileRevalidate === "number"
|
|
1859
|
+
? raw.staleWhileRevalidate
|
|
1860
|
+
: typeof raw.swr === "number"
|
|
1861
|
+
? raw.swr
|
|
1862
|
+
: undefined;
|
|
1863
|
+
return {
|
|
1864
|
+
data,
|
|
1865
|
+
meta: { tags, maxAge: maxAgeRaw, staleWhileRevalidate: swrRaw },
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
return { data: returned, meta: null };
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* Phase 18.ζ — ctx.cache 스냅샷과 `_cache` 리턴 메타데이터를 병합한다.
|
|
1873
|
+
* 둘 다 없으면 null. 태그는 합집합, 숫자는 return 값이 우선.
|
|
1874
|
+
*/
|
|
1875
|
+
function mergeRuntimeCacheMeta(
|
|
1876
|
+
fromReturn: RuntimeCacheMeta | null,
|
|
1877
|
+
fromCtx: { tags: string[]; maxAge?: number; staleWhileRevalidate?: number } | null
|
|
1878
|
+
): RuntimeCacheMeta | null {
|
|
1879
|
+
if (!fromReturn && !fromCtx) return null;
|
|
1880
|
+
const tags = new Set<string>();
|
|
1881
|
+
fromReturn?.tags?.forEach((t) => tags.add(t));
|
|
1882
|
+
fromCtx?.tags?.forEach((t) => tags.add(t));
|
|
1883
|
+
return {
|
|
1884
|
+
tags: [...tags],
|
|
1885
|
+
maxAge: fromReturn?.maxAge ?? fromCtx?.maxAge,
|
|
1886
|
+
staleWhileRevalidate: fromReturn?.staleWhileRevalidate ?? fromCtx?.staleWhileRevalidate,
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1659
1890
|
/**
|
|
1660
1891
|
* 페이지 컴포넌트 및 loader 데이터 로딩
|
|
1661
1892
|
*/
|
|
@@ -1710,10 +1941,16 @@ async function loadPageData(
|
|
|
1710
1941
|
const redirectResponse = mergeCookiesIntoResponse(returned, ctx.cookies);
|
|
1711
1942
|
return ok({ loaderData: undefined, redirect: redirectResponse });
|
|
1712
1943
|
}
|
|
1713
|
-
|
|
1944
|
+
// Phase 18.ζ — per-request cache meta collection
|
|
1945
|
+
const { data: strippedData, meta: returnMeta } = extractCacheMetaFromReturn(returned);
|
|
1946
|
+
const mergedMeta = mergeRuntimeCacheMeta(returnMeta, ctx.getCacheMetaSnapshot());
|
|
1947
|
+
loaderData = strippedData;
|
|
1714
1948
|
if (ctx.cookies.hasPendingCookies()) {
|
|
1715
1949
|
cookies = ctx.cookies;
|
|
1716
1950
|
}
|
|
1951
|
+
if (mergedMeta) {
|
|
1952
|
+
return ok({ loaderData, cookies, cacheMeta: mergedMeta });
|
|
1953
|
+
}
|
|
1717
1954
|
}
|
|
1718
1955
|
} catch (error) {
|
|
1719
1956
|
const pageError = createPageLoadErrorResponse(
|
|
@@ -1816,10 +2053,16 @@ async function loadPageData(
|
|
|
1816
2053
|
const redirectResponse = mergeCookiesIntoResponse(returned, ctx.cookies);
|
|
1817
2054
|
return ok({ loaderData: undefined, redirect: redirectResponse });
|
|
1818
2055
|
}
|
|
1819
|
-
|
|
2056
|
+
// Phase 18.ζ — per-request cache meta collection (legacy pageLoader path)
|
|
2057
|
+
const { data: strippedData, meta: returnMeta } = extractCacheMetaFromReturn(returned);
|
|
2058
|
+
const mergedMeta = mergeRuntimeCacheMeta(returnMeta, ctx.getCacheMetaSnapshot());
|
|
2059
|
+
loaderData = strippedData;
|
|
1820
2060
|
if (ctx.cookies.hasPendingCookies()) {
|
|
1821
2061
|
cookies = ctx.cookies;
|
|
1822
2062
|
}
|
|
2063
|
+
if (mergedMeta) {
|
|
2064
|
+
return ok({ loaderData, cookies, cacheMeta: mergedMeta });
|
|
2065
|
+
}
|
|
1823
2066
|
}
|
|
1824
2067
|
|
|
1825
2068
|
return ok({ loaderData, cookies });
|
|
@@ -2536,7 +2779,19 @@ async function handlePageRoute(
|
|
|
2536
2779
|
// Shell MISS: fall through to full render, then cache the shell below
|
|
2537
2780
|
}
|
|
2538
2781
|
|
|
2539
|
-
//
|
|
2782
|
+
// ─── Phase 18.ζ — ISR + cache-tags dispatch ────────────────────────────
|
|
2783
|
+
// Runs AFTER γ's prerendered pass-through (handled earlier in
|
|
2784
|
+
// `dispatchRequest`) and BEFORE full route dispatch.
|
|
2785
|
+
// HIT : serve fresh cache entry directly, no SSR work.
|
|
2786
|
+
// STALE : serve stale HTML immediately + background revalidation
|
|
2787
|
+
// under a per-key mutex (`pendingRevalidations`).
|
|
2788
|
+
// MISS : fall through to render. Save happens below (step 4b)
|
|
2789
|
+
// after `_cache` / `ctx.cache` metadata has been collected
|
|
2790
|
+
// from the loader return.
|
|
2791
|
+
//
|
|
2792
|
+
// Served responses carry `Cache-Control: public, max-age=…,
|
|
2793
|
+
// stale-while-revalidate=…` for CDN alignment plus the debug header
|
|
2794
|
+
// `X-Mandu-Cache: HIT|STALE`.
|
|
2540
2795
|
if (cache && !isDataRequest && renderMode !== "dynamic" && renderMode !== "ppr") {
|
|
2541
2796
|
const cacheKey = buildRouteCacheKey(route.id, url);
|
|
2542
2797
|
const lookup = lookupCache(cache, cacheKey);
|
|
@@ -2563,6 +2818,7 @@ async function handlePageRoute(
|
|
|
2563
2818
|
return ok(createCachedResponse(lookup.entry, "STALE"));
|
|
2564
2819
|
}
|
|
2565
2820
|
}
|
|
2821
|
+
// ─── End Phase 18.ζ ────────────────────────────────────────────────────
|
|
2566
2822
|
|
|
2567
2823
|
// 1. 페이지 + 레이아웃 데이터 병렬 로딩
|
|
2568
2824
|
const [loadResult, layoutLoad] = await Promise.all([
|
|
@@ -2671,22 +2927,66 @@ async function handlePageRoute(
|
|
|
2671
2927
|
}).catch(() => {});
|
|
2672
2928
|
}
|
|
2673
2929
|
|
|
2674
|
-
//
|
|
2930
|
+
// ─── Phase 18.ζ — ISR/SWR cache save (step 4b) ────────────────────────
|
|
2931
|
+
// Resolves the effective cache metadata from three tiers (per-request
|
|
2932
|
+
// `_cache` / `ctx.cache` → route-level `filling.loader(fn, {...})` →
|
|
2933
|
+
// global `ManduConfig.cache.defaultMaxAge/defaultSwr`). When a valid
|
|
2934
|
+
// `maxAge > 0` is resolved, we:
|
|
2935
|
+
// 1. Persist the rendered HTML under the same key used by the HIT
|
|
2936
|
+
// path above (`buildRouteCacheKey`).
|
|
2937
|
+
// 2. Stamp the outgoing Response with `Cache-Control: public,
|
|
2938
|
+
// max-age=…, stale-while-revalidate=…` + `X-Mandu-Cache: MISS` so
|
|
2939
|
+
// CDNs and browsers can coordinate the same TTL we stored.
|
|
2940
|
+
// The persisted entry has `tags` union'd across tiers — invalidation
|
|
2941
|
+
// via `revalidateTag()` therefore hits both developer-declared tags
|
|
2942
|
+
// and per-request tags for this URL.
|
|
2675
2943
|
if (cache && ssrResult.ok && renderMode !== "dynamic" && renderMode !== "ppr") {
|
|
2676
|
-
const
|
|
2677
|
-
|
|
2944
|
+
const perRequest = loadResult.value.cacheMeta;
|
|
2945
|
+
const resolved = resolveCacheMetaForSave(route.id, registry, perRequest);
|
|
2946
|
+
if (resolved) {
|
|
2678
2947
|
const cloned = ssrResult.value.clone();
|
|
2679
2948
|
const status = ssrResult.value.status;
|
|
2680
2949
|
const headers = Object.fromEntries(ssrResult.value.headers.entries());
|
|
2681
2950
|
const cacheKey = buildRouteCacheKey(route.id, url);
|
|
2682
2951
|
// streaming 응답도 블로킹하지 않도록 백그라운드에서 캐시 저장
|
|
2683
2952
|
cloned.text().then((html) => {
|
|
2684
|
-
cache.set(
|
|
2685
|
-
|
|
2686
|
-
|
|
2953
|
+
cache.set(
|
|
2954
|
+
cacheKey,
|
|
2955
|
+
createCacheEntry(
|
|
2956
|
+
html,
|
|
2957
|
+
loaderData,
|
|
2958
|
+
{
|
|
2959
|
+
maxAge: resolved.maxAge,
|
|
2960
|
+
staleWhileRevalidate: resolved.staleWhileRevalidate,
|
|
2961
|
+
tags: resolved.tags,
|
|
2962
|
+
},
|
|
2963
|
+
status,
|
|
2964
|
+
headers
|
|
2965
|
+
)
|
|
2966
|
+
);
|
|
2687
2967
|
}).catch(() => {});
|
|
2968
|
+
|
|
2969
|
+
// Stamp Cache-Control + X-Mandu-Cache on the MISS response so
|
|
2970
|
+
// downstream CDNs honor the same TTL we just persisted.
|
|
2971
|
+
const freshEntryPreview = createCacheEntry(
|
|
2972
|
+
"",
|
|
2973
|
+
null,
|
|
2974
|
+
{ maxAge: resolved.maxAge, staleWhileRevalidate: resolved.staleWhileRevalidate, tags: resolved.tags },
|
|
2975
|
+
status,
|
|
2976
|
+
{}
|
|
2977
|
+
);
|
|
2978
|
+
const cc = computeCacheControl(freshEntryPreview);
|
|
2979
|
+
const stamped = new Response(ssrResult.value.body, {
|
|
2980
|
+
status: ssrResult.value.status,
|
|
2981
|
+
statusText: ssrResult.value.statusText,
|
|
2982
|
+
headers: ssrResult.value.headers,
|
|
2983
|
+
});
|
|
2984
|
+
stamped.headers.set("Cache-Control", cc);
|
|
2985
|
+
stamped.headers.set("X-Mandu-Cache", "MISS");
|
|
2986
|
+
return ok(stamped);
|
|
2688
2987
|
}
|
|
2689
2988
|
}
|
|
2989
|
+
// ─── End Phase 18.ζ ────────────────────────────────────────────────────
|
|
2690
2990
|
|
|
2691
2991
|
return ssrResult;
|
|
2692
2992
|
}
|
|
@@ -2719,28 +3019,89 @@ async function regenerateCache(
|
|
|
2719
3019
|
const ssrResult = await renderPageSSR(route, params, loaderData, req.url, registry, undefined, layoutData);
|
|
2720
3020
|
if (!ssrResult.ok) return;
|
|
2721
3021
|
|
|
2722
|
-
|
|
2723
|
-
|
|
3022
|
+
// Phase 18.ζ — resolve meta with the same 3-tier priority as the MISS
|
|
3023
|
+
// path so background revalidation honors per-request `_cache` from the
|
|
3024
|
+
// freshly re-executed loader.
|
|
3025
|
+
const resolved = resolveCacheMetaForSave(
|
|
3026
|
+
route.id,
|
|
3027
|
+
registry,
|
|
3028
|
+
loadResult.value.cacheMeta
|
|
3029
|
+
);
|
|
3030
|
+
if (!resolved) return;
|
|
2724
3031
|
|
|
2725
3032
|
const html = await ssrResult.value.text();
|
|
2726
3033
|
const entry = createCacheEntry(
|
|
2727
3034
|
html,
|
|
2728
3035
|
loaderData,
|
|
2729
|
-
|
|
2730
|
-
|
|
3036
|
+
{
|
|
3037
|
+
maxAge: resolved.maxAge,
|
|
3038
|
+
staleWhileRevalidate: resolved.staleWhileRevalidate,
|
|
3039
|
+
tags: resolved.tags,
|
|
3040
|
+
},
|
|
2731
3041
|
ssrResult.value.status,
|
|
2732
3042
|
Object.fromEntries(ssrResult.value.headers.entries())
|
|
2733
3043
|
);
|
|
2734
3044
|
cache.set(cacheKey, entry);
|
|
2735
3045
|
}
|
|
2736
3046
|
|
|
3047
|
+
/**
|
|
3048
|
+
* Phase 18.ζ — 최종 캐시 엔트리 stamp 용 메타데이터 해석.
|
|
3049
|
+
*
|
|
3050
|
+
* 우선순위 (높음 → 낮음):
|
|
3051
|
+
* 1. per-request: `loadResult.value.cacheMeta` (`_cache` 또는 `ctx.cache`)
|
|
3052
|
+
* 2. route-level: `filling.getCacheOptions()` (`.loader(fn, {revalidate, tags})`)
|
|
3053
|
+
* 3. global: `ManduConfig.cache.defaultMaxAge` / `defaultSwr`
|
|
3054
|
+
*
|
|
3055
|
+
* 반환값은 다음 의미를 가진다:
|
|
3056
|
+
* - `null` → 저장하지 않음 (maxAge 가 0 이하이거나 캐싱 의사 없음).
|
|
3057
|
+
* - `{ maxAge, staleWhileRevalidate, tags }` → 저장.
|
|
3058
|
+
*
|
|
3059
|
+
* 태그는 per-request + route-level 의 합집합이다.
|
|
3060
|
+
*/
|
|
3061
|
+
function resolveCacheMetaForSave(
|
|
3062
|
+
routeId: string,
|
|
3063
|
+
registry: ServerRegistry,
|
|
3064
|
+
perRequest: RuntimeCacheMeta | undefined
|
|
3065
|
+
): { maxAge: number; staleWhileRevalidate: number; tags: string[] } | null {
|
|
3066
|
+
const routeLevel = registry.cacheOptions?.get(routeId) ?? null;
|
|
3067
|
+
// `getGlobalCacheDefaults` is imported from ./cache; defaults may be null.
|
|
3068
|
+
const defaults = (function () {
|
|
3069
|
+
try {
|
|
3070
|
+
// Dynamic require-free import: re-expose via the module we already
|
|
3071
|
+
// depend on at the top of this file (`./cache`).
|
|
3072
|
+
return getGlobalCacheDefaults();
|
|
3073
|
+
} catch {
|
|
3074
|
+
return null;
|
|
3075
|
+
}
|
|
3076
|
+
})();
|
|
3077
|
+
|
|
3078
|
+
const maxAge =
|
|
3079
|
+
perRequest?.maxAge ??
|
|
3080
|
+
routeLevel?.revalidate ??
|
|
3081
|
+
defaults?.defaultMaxAge ??
|
|
3082
|
+
0;
|
|
3083
|
+
const swr =
|
|
3084
|
+
perRequest?.staleWhileRevalidate ??
|
|
3085
|
+
routeLevel?.staleWhileRevalidate ??
|
|
3086
|
+
defaults?.defaultSwr ??
|
|
3087
|
+
0;
|
|
3088
|
+
|
|
3089
|
+
if (!Number.isFinite(maxAge) || maxAge <= 0) return null;
|
|
3090
|
+
|
|
3091
|
+
const tagSet = new Set<string>();
|
|
3092
|
+
routeLevel?.tags?.forEach((t) => tagSet.add(t));
|
|
3093
|
+
perRequest?.tags?.forEach((t) => tagSet.add(t));
|
|
3094
|
+
|
|
3095
|
+
return { maxAge, staleWhileRevalidate: Math.max(0, swr), tags: [...tagSet] };
|
|
3096
|
+
}
|
|
3097
|
+
|
|
2737
3098
|
/**
|
|
2738
3099
|
* 라우트의 캐시 옵션 가져오기 (pageHandler의 filling에서 추출)
|
|
2739
3100
|
*/
|
|
2740
3101
|
function getCacheOptionsForRoute(
|
|
2741
3102
|
routeId: string,
|
|
2742
3103
|
registry: ServerRegistry
|
|
2743
|
-
): { revalidate?: number; tags?: string[] } | null {
|
|
3104
|
+
): { revalidate?: number; staleWhileRevalidate?: number; tags?: string[] } | null {
|
|
2744
3105
|
const pageHandler = registry.pageHandlers.get(routeId);
|
|
2745
3106
|
if (!pageHandler) return null;
|
|
2746
3107
|
|
|
@@ -3008,6 +3369,47 @@ async function handleRequestInternal(
|
|
|
3008
3369
|
}
|
|
3009
3370
|
// ─── End Phase 18.ε ──────────────────────────────────────────────────────
|
|
3010
3371
|
|
|
3372
|
+
// ─── Phase 18.κ — typed RPC dispatch ──────────────────────────────────────
|
|
3373
|
+
// Runs AFTER γ's prerendered pass-through (handled earlier at step 0.5),
|
|
3374
|
+
// AFTER ζ's ISR/SWR cache check (per-route, inside handlePageRoute), and
|
|
3375
|
+
// BEFORE β's file-system route dispatch below. The canonical URL shape is
|
|
3376
|
+
//
|
|
3377
|
+
// POST /api/rpc/<endpoint>/<method>
|
|
3378
|
+
//
|
|
3379
|
+
// with JSON body `{ input: <value> }`. The dispatcher:
|
|
3380
|
+
// 1. matches `/api/rpc/<name>/<method>` via `matchRpcPath()` (returns
|
|
3381
|
+
// `null` for any other path — then we fall through to β),
|
|
3382
|
+
// 2. looks up the registered `RpcDefinition` in the module-level
|
|
3383
|
+
// `rpcRegistry` (populated by `registerRpc` at boot from
|
|
3384
|
+
// `ServerOptions.rpc.endpoints`),
|
|
3385
|
+
// 3. validates the request body against `procedure.input` (Zod),
|
|
3386
|
+
// invokes `procedure.handler`, validates the return value against
|
|
3387
|
+
// `procedure.output` (Zod), and ships a
|
|
3388
|
+
// `{ ok: true, data } | { ok: false, error }` JSON envelope.
|
|
3389
|
+
//
|
|
3390
|
+
// All failure paths return structured envelopes — never throws — so the
|
|
3391
|
+
// outer request handler's 5xx catch is unreachable on the happy path.
|
|
3392
|
+
// See `packages/core/src/contract/rpc.ts` and
|
|
3393
|
+
// `docs/architect/typed-rpc.md`.
|
|
3394
|
+
{
|
|
3395
|
+
const rpcMatch = matchRpcPath(pathname);
|
|
3396
|
+
if (rpcMatch) {
|
|
3397
|
+
const rpcResponse = await dispatchRpc(
|
|
3398
|
+
req,
|
|
3399
|
+
rpcMatch.endpoint,
|
|
3400
|
+
rpcMatch.method,
|
|
3401
|
+
{ isDev: settings.isDev }
|
|
3402
|
+
);
|
|
3403
|
+
if (settings.cors && isCorsRequest(req)) {
|
|
3404
|
+
const corsOptions: CorsOptions =
|
|
3405
|
+
typeof settings.cors === "object" ? settings.cors : {};
|
|
3406
|
+
return ok(applyCorsToResponse(rpcResponse, req, corsOptions));
|
|
3407
|
+
}
|
|
3408
|
+
return ok(rpcResponse);
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
// ─── End Phase 18.κ ───────────────────────────────────────────────────────
|
|
3412
|
+
|
|
3011
3413
|
// 3. 라우트 매칭
|
|
3012
3414
|
const match = router.match(pathname);
|
|
3013
3415
|
if (!match) {
|
|
@@ -3206,6 +3608,8 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
3206
3608
|
observability: observabilityOption,
|
|
3207
3609
|
prerender: prerenderOption,
|
|
3208
3610
|
middleware: middlewareOption,
|
|
3611
|
+
rpc: rpcOption,
|
|
3612
|
+
scheduler: schedulerOption,
|
|
3209
3613
|
} = options;
|
|
3210
3614
|
|
|
3211
3615
|
// Phase 18.ε — build the request-level middleware chain once at boot.
|
|
@@ -3255,6 +3659,18 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
3255
3659
|
console.warn(" cors: { origin: ['https://yourdomain.com'] }");
|
|
3256
3660
|
}
|
|
3257
3661
|
|
|
3662
|
+
// Phase 18.θ — build a tracer once at boot. Honours
|
|
3663
|
+
// `observability.tracing` in options AND `MANDU_OTEL_ENDPOINT` env var.
|
|
3664
|
+
// When both are absent, `createTracerFromConfig({})` returns a disabled
|
|
3665
|
+
// tracer (no allocations, no per-request overhead).
|
|
3666
|
+
const tracerInstance: Tracer = createTracerFromConfig(
|
|
3667
|
+
observabilityOption?.tracing
|
|
3668
|
+
);
|
|
3669
|
+
// Install as process-global so `@mandujs/core/observability`
|
|
3670
|
+
// `getTracer()` returns the same instance user code sees through
|
|
3671
|
+
// `ctx.startSpan(...)`.
|
|
3672
|
+
setTracer(tracerInstance);
|
|
3673
|
+
|
|
3258
3674
|
// Registry settings 저장 (초기값)
|
|
3259
3675
|
registry.settings = {
|
|
3260
3676
|
isDev,
|
|
@@ -3273,18 +3689,68 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
3273
3689
|
devtools,
|
|
3274
3690
|
heapEndpoint: observabilityOption?.heapEndpoint,
|
|
3275
3691
|
metricsEndpoint: observabilityOption?.metricsEndpoint,
|
|
3692
|
+
tracer: tracerInstance.enabled ? tracerInstance : undefined,
|
|
3276
3693
|
prerender: prerenderSettings,
|
|
3277
3694
|
middlewareChain,
|
|
3278
3695
|
};
|
|
3279
3696
|
|
|
3280
3697
|
registry.rateLimiter = rateLimitOptions ? new MemoryRateLimiter() : null;
|
|
3281
3698
|
|
|
3282
|
-
//
|
|
3699
|
+
// ─── Phase 18.κ — register RPC endpoints from options ──────────────────
|
|
3700
|
+
// The RPC registry is module-scoped (shared across all server
|
|
3701
|
+
// instances in this process). Clearing first keeps repeated
|
|
3702
|
+
// `startServer()` calls in tests deterministic — otherwise a stale
|
|
3703
|
+
// endpoint from a prior run could answer a later instance's requests.
|
|
3704
|
+
//
|
|
3705
|
+
// This runs once at boot; HMR-time re-registration goes through the
|
|
3706
|
+
// exported `registerRpc()` from `@mandujs/core/contract/rpc`.
|
|
3707
|
+
clearRpcRegistry();
|
|
3708
|
+
if (rpcOption?.endpoints) {
|
|
3709
|
+
for (const [name, definition] of Object.entries(rpcOption.endpoints)) {
|
|
3710
|
+
registerRpc(name, definition);
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
// ─── End Phase 18.κ ────────────────────────────────────────────────────
|
|
3714
|
+
|
|
3715
|
+
// ─── Phase 18.ζ — ISR/SWR 캐시 초기화 ──────────────────────────────────
|
|
3716
|
+
// `cacheOption` 는 `true` | `false` | `CacheStore` | `CacheConfig` 를 받는다:
|
|
3717
|
+
// - `true` → MemoryCacheStore(1000) 를 기본값으로 생성.
|
|
3718
|
+
// - `CacheStore` → 그대로 주입 (커스텀 어댑터, 예: redis).
|
|
3719
|
+
// - `CacheConfig` → { defaultMaxAge, defaultSwr, maxEntries, store }
|
|
3720
|
+
// 에서 store="memory"(default) 로 MemoryCacheStore 생성
|
|
3721
|
+
// 후 defaults 를 global 에 기록하여 per-request `_cache`
|
|
3722
|
+
// 가 누락된 경우에도 자동 캐싱할 수 있게 한다.
|
|
3723
|
+
// - `false`/미지정 → 캐시 disabled.
|
|
3283
3724
|
if (cacheOption) {
|
|
3284
|
-
const store =
|
|
3285
|
-
|
|
3286
|
-
|
|
3725
|
+
const store = createCacheStoreFromConfig(
|
|
3726
|
+
cacheOption as boolean | CacheStore | CacheConfig
|
|
3727
|
+
);
|
|
3728
|
+
if (store) {
|
|
3729
|
+
registry.settings.cacheStore = store;
|
|
3730
|
+
setGlobalCache(store); // revalidatePath/revalidateTag API에서 사용
|
|
3731
|
+
|
|
3732
|
+
// CacheConfig 객체일 때만 defaults 를 전역에 등록. boolean/CacheStore
|
|
3733
|
+
// 형태로 들어오면 기존 동작 (per-route 설정 없으면 저장 안 함) 유지.
|
|
3734
|
+
if (
|
|
3735
|
+
typeof cacheOption === "object" &&
|
|
3736
|
+
cacheOption !== null &&
|
|
3737
|
+
!(typeof (cacheOption as CacheStore).get === "function")
|
|
3738
|
+
) {
|
|
3739
|
+
const cfg = cacheOption as CacheConfig;
|
|
3740
|
+
setGlobalCacheDefaults({
|
|
3741
|
+
defaultMaxAge: cfg.defaultMaxAge,
|
|
3742
|
+
defaultSwr: cfg.defaultSwr,
|
|
3743
|
+
});
|
|
3744
|
+
} else {
|
|
3745
|
+
setGlobalCacheDefaults(null);
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
} else {
|
|
3749
|
+
// 캐시 disabled — 이전 테스트 run 의 defaults 가 새 서버 instance 에
|
|
3750
|
+
// 새어 들어오지 않도록 초기화.
|
|
3751
|
+
setGlobalCacheDefaults(null);
|
|
3287
3752
|
}
|
|
3753
|
+
// ─── End Phase 18.ζ ────────────────────────────────────────────────────
|
|
3288
3754
|
|
|
3289
3755
|
// Kitchen dev dashboard (dev mode only)
|
|
3290
3756
|
if (isDev) {
|
|
@@ -3425,12 +3891,64 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
|
|
|
3425
3891
|
}
|
|
3426
3892
|
}
|
|
3427
3893
|
|
|
3894
|
+
// ─── Phase 18.λ — declarative cron scheduler ────────────────────────────
|
|
3895
|
+
// Boot the scheduler AFTER the HTTP listener is live so a malformed cron
|
|
3896
|
+
// expression (caught by validateCronExpression) surfaces alongside the
|
|
3897
|
+
// other boot errors rather than aborting the server. `startServer` is
|
|
3898
|
+
// synchronous by contract, so we use the already-imported scheduler
|
|
3899
|
+
// module rather than `await import`.
|
|
3900
|
+
let schedulerRegistration: import("../scheduler").CronRegistration | null = null;
|
|
3901
|
+
const jobDefs = schedulerOption?.jobs ?? [];
|
|
3902
|
+
const schedulerDisabled = schedulerOption?.disabled === true;
|
|
3903
|
+
if (jobDefs.length > 0 && !schedulerDisabled) {
|
|
3904
|
+
try {
|
|
3905
|
+
schedulerRegistration = schedulerDefineCron(jobDefs);
|
|
3906
|
+
schedulerRegistration.start();
|
|
3907
|
+
setActiveSchedulerRegistration(schedulerRegistration);
|
|
3908
|
+
const bunJobCount = Object.keys(schedulerRegistration.status()).filter(
|
|
3909
|
+
(name) => {
|
|
3910
|
+
const def = jobDefs.find((j) => j.name === name);
|
|
3911
|
+
const runOn = def?.runOn && def.runOn.length > 0 ? def.runOn : ["bun", "workers"];
|
|
3912
|
+
return runOn.includes("bun");
|
|
3913
|
+
},
|
|
3914
|
+
).length;
|
|
3915
|
+
console.log(
|
|
3916
|
+
`⏰ Scheduler: ${bunJobCount} cron job(s) registered on Bun runtime` +
|
|
3917
|
+
(jobDefs.length !== bunJobCount
|
|
3918
|
+
? ` (${jobDefs.length - bunJobCount} workers-only — see wrangler.toml)`
|
|
3919
|
+
: ""),
|
|
3920
|
+
);
|
|
3921
|
+
} catch (err) {
|
|
3922
|
+
// Scheduler failures MUST NOT crash the server — a bad cron string is
|
|
3923
|
+
// a developer error, but the HTTP surface should keep serving. Log
|
|
3924
|
+
// loudly and leave the registration null.
|
|
3925
|
+
console.error(
|
|
3926
|
+
"❌ [scheduler] failed to start — HTTP server continues without cron jobs:",
|
|
3927
|
+
err instanceof Error ? err.message : err,
|
|
3928
|
+
);
|
|
3929
|
+
}
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3428
3932
|
return {
|
|
3429
3933
|
server,
|
|
3430
3934
|
router,
|
|
3431
3935
|
registry,
|
|
3432
3936
|
stop: () => {
|
|
3433
3937
|
registry.kitchen?.stop();
|
|
3938
|
+
// Fire-and-forget the async scheduler drain so `stop()` stays
|
|
3939
|
+
// synchronous for backwards compatibility with existing consumers.
|
|
3940
|
+
// Tests that need to await drain can reach for `registration.stop()`
|
|
3941
|
+
// directly; `server.stop()` triggers shutdown but doesn't block on
|
|
3942
|
+
// in-flight cron handler completion here.
|
|
3943
|
+
if (schedulerRegistration) {
|
|
3944
|
+
const reg = schedulerRegistration;
|
|
3945
|
+
schedulerRegistration = null;
|
|
3946
|
+
void reg.stop()
|
|
3947
|
+
.then(() => setActiveSchedulerRegistration(null))
|
|
3948
|
+
.catch((err) => {
|
|
3949
|
+
console.error("[scheduler] shutdown error:", err);
|
|
3950
|
+
});
|
|
3951
|
+
}
|
|
3434
3952
|
server.stop();
|
|
3435
3953
|
},
|
|
3436
3954
|
};
|