@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/filling/context.ts
CHANGED
|
@@ -12,6 +12,14 @@ import { ContractValidator, type ContractValidatorOptions } from "../contract/va
|
|
|
12
12
|
import { getCookieCodec } from "./cookie-codec";
|
|
13
13
|
import { type FillingDeps, globalDeps } from "./deps";
|
|
14
14
|
import { createSSEConnection, type SSEOptions, type SSEConnection } from "./sse";
|
|
15
|
+
import {
|
|
16
|
+
getActiveSpan,
|
|
17
|
+
getTracer,
|
|
18
|
+
runWithSpan,
|
|
19
|
+
type Span,
|
|
20
|
+
type SpanAttributes,
|
|
21
|
+
type SpanOptions,
|
|
22
|
+
} from "../observability/tracing";
|
|
15
23
|
|
|
16
24
|
type ContractInput<
|
|
17
25
|
TContract extends ContractSchema,
|
|
@@ -285,12 +293,45 @@ async function hmacSign(data: string, secret: string): Promise<string> {
|
|
|
285
293
|
|
|
286
294
|
// ========== ManduContext ==========
|
|
287
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Phase 18.ζ — 로더가 캐시 메타데이터를 선언하기 위한 플루언트 헬퍼.
|
|
298
|
+
*
|
|
299
|
+
* 내부적으로는 `ctx._cacheMeta` 에 누적하며, 서버 런타임이 loader 실행
|
|
300
|
+
* 직후 읽어 `_cache` 블록과 동일하게 해석한다. 반환 데이터에 `_cache` 를
|
|
301
|
+
* 끼워넣는 방식과 동일한 의미지만, 타입 안정성이 더 높다.
|
|
302
|
+
*
|
|
303
|
+
* @example
|
|
304
|
+
* ```ts
|
|
305
|
+
* .loader(async (ctx) => {
|
|
306
|
+
* ctx.cache.tag("posts").tag("posts:42").maxAge(3600).swr(86400);
|
|
307
|
+
* return { post: await db.getPost(42) };
|
|
308
|
+
* })
|
|
309
|
+
* ```
|
|
310
|
+
*/
|
|
311
|
+
export interface CacheHelper {
|
|
312
|
+
tag(...tags: string[]): CacheHelper;
|
|
313
|
+
maxAge(seconds: number): CacheHelper;
|
|
314
|
+
/** alias for maxAge — Next.js parity */
|
|
315
|
+
revalidate(seconds: number): CacheHelper;
|
|
316
|
+
swr(seconds: number): CacheHelper;
|
|
317
|
+
/** alias for swr */
|
|
318
|
+
staleWhileRevalidate(seconds: number): CacheHelper;
|
|
319
|
+
/** 현재까지 누적된 메타데이터 스냅샷 (런타임 내부용) */
|
|
320
|
+
readonly meta: {
|
|
321
|
+
tags: string[];
|
|
322
|
+
maxAge?: number;
|
|
323
|
+
staleWhileRevalidate?: number;
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
288
327
|
export class ManduContext {
|
|
289
328
|
private store: Map<string, unknown> = new Map();
|
|
290
329
|
private _params: Record<string, string>;
|
|
291
330
|
private _query: Record<string, string>;
|
|
292
331
|
private _cookies: CookieManager;
|
|
293
332
|
private _deps: FillingDeps;
|
|
333
|
+
private _cacheMeta: { tags: string[]; maxAge?: number; staleWhileRevalidate?: number } = { tags: [] };
|
|
334
|
+
private _cacheHelper: CacheHelper | null = null;
|
|
294
335
|
|
|
295
336
|
constructor(
|
|
296
337
|
public readonly request: Request,
|
|
@@ -303,6 +344,59 @@ export class ManduContext {
|
|
|
303
344
|
this._deps = deps ?? globalDeps.get();
|
|
304
345
|
}
|
|
305
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Phase 18.ζ — 로더 내부에서 호출하는 캐시 메타데이터 플루언트 헬퍼.
|
|
349
|
+
* 누적 호출 가능: `ctx.cache.tag("a", "b").maxAge(60).swr(300)`
|
|
350
|
+
*/
|
|
351
|
+
get cache(): CacheHelper {
|
|
352
|
+
if (this._cacheHelper) return this._cacheHelper;
|
|
353
|
+
const meta = this._cacheMeta;
|
|
354
|
+
const helper: CacheHelper = {
|
|
355
|
+
tag: (...tags: string[]) => {
|
|
356
|
+
for (const t of tags) {
|
|
357
|
+
if (typeof t === "string" && t.length > 0 && !meta.tags.includes(t)) {
|
|
358
|
+
meta.tags.push(t);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return helper;
|
|
362
|
+
},
|
|
363
|
+
maxAge: (seconds: number) => {
|
|
364
|
+
if (Number.isFinite(seconds) && seconds >= 0) meta.maxAge = seconds;
|
|
365
|
+
return helper;
|
|
366
|
+
},
|
|
367
|
+
revalidate: (seconds: number) => helper.maxAge(seconds),
|
|
368
|
+
swr: (seconds: number) => {
|
|
369
|
+
if (Number.isFinite(seconds) && seconds >= 0) meta.staleWhileRevalidate = seconds;
|
|
370
|
+
return helper;
|
|
371
|
+
},
|
|
372
|
+
staleWhileRevalidate: (seconds: number) => helper.swr(seconds),
|
|
373
|
+
get meta() {
|
|
374
|
+
return {
|
|
375
|
+
tags: [...meta.tags],
|
|
376
|
+
maxAge: meta.maxAge,
|
|
377
|
+
staleWhileRevalidate: meta.staleWhileRevalidate,
|
|
378
|
+
};
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
this._cacheHelper = helper;
|
|
382
|
+
return helper;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Phase 18.ζ — 서버 런타임이 loader 종료 후 누적된 메타 스냅샷을 읽는다.
|
|
387
|
+
* 내부 전용. 유저 코드는 `ctx.cache` 를 사용할 것.
|
|
388
|
+
*/
|
|
389
|
+
getCacheMetaSnapshot(): { tags: string[]; maxAge?: number; staleWhileRevalidate?: number } | null {
|
|
390
|
+
if (this._cacheMeta.tags.length === 0 && this._cacheMeta.maxAge === undefined && this._cacheMeta.staleWhileRevalidate === undefined) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
tags: [...this._cacheMeta.tags],
|
|
395
|
+
maxAge: this._cacheMeta.maxAge,
|
|
396
|
+
staleWhileRevalidate: this._cacheMeta.staleWhileRevalidate,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
306
400
|
/**
|
|
307
401
|
* DNA-002: 의존성 접근
|
|
308
402
|
*
|
|
@@ -631,6 +725,78 @@ export class ManduContext {
|
|
|
631
725
|
has(key: string): boolean {
|
|
632
726
|
return this.store.has(key);
|
|
633
727
|
}
|
|
728
|
+
|
|
729
|
+
// ============================================
|
|
730
|
+
// 🥟 Tracing (Phase 18.θ)
|
|
731
|
+
// ============================================
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* The currently-active tracing span for this request, or `undefined`
|
|
735
|
+
* when tracing is disabled. Looked up via AsyncLocalStorage so it
|
|
736
|
+
* stays accurate across nested awaits — a loader that opens a child
|
|
737
|
+
* span before hitting an external API will see `ctx.span` return
|
|
738
|
+
* that child span until the child's `end()` fires.
|
|
739
|
+
*
|
|
740
|
+
* When tracing is disabled this reads as `undefined` (the runtime
|
|
741
|
+
* never opens a recording span), keeping the hot path free of
|
|
742
|
+
* extra allocations.
|
|
743
|
+
*/
|
|
744
|
+
get span(): Span | undefined {
|
|
745
|
+
const active = getActiveSpan();
|
|
746
|
+
return active && active.recording ? active : undefined;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Open a child span scoped to the async context of `fn`. The span
|
|
751
|
+
* auto-ends when the returned promise resolves; if `fn` throws, the
|
|
752
|
+
* span is marked `status=error` with the thrown message, the span
|
|
753
|
+
* is ended, and the error re-thrown.
|
|
754
|
+
*
|
|
755
|
+
* When tracing is disabled this degenerates to just `await fn(noopSpan)`
|
|
756
|
+
* with no allocation in the hot path — callers don't have to branch
|
|
757
|
+
* on `ctx.span` themselves.
|
|
758
|
+
*
|
|
759
|
+
* @example
|
|
760
|
+
* const users = await ctx.startSpan("db.users.findAll", async (span) => {
|
|
761
|
+
* span.setAttribute("db.system", "postgres");
|
|
762
|
+
* return await sql\`SELECT * FROM users\`;
|
|
763
|
+
* });
|
|
764
|
+
*/
|
|
765
|
+
async startSpan<T>(
|
|
766
|
+
name: string,
|
|
767
|
+
fn: (span: Span) => Promise<T> | T,
|
|
768
|
+
opts: SpanOptions = {}
|
|
769
|
+
): Promise<T> {
|
|
770
|
+
const tracer = getTracer();
|
|
771
|
+
if (!tracer.enabled) {
|
|
772
|
+
// Fast path: invoke fn with a shared no-op span (type-shape
|
|
773
|
+
// identical, zero allocations).
|
|
774
|
+
const { startSpan } = tracer;
|
|
775
|
+
const span = startSpan.call(tracer, name, opts);
|
|
776
|
+
return await fn(span);
|
|
777
|
+
}
|
|
778
|
+
const span = tracer.startSpan(name, opts);
|
|
779
|
+
try {
|
|
780
|
+
const result = await runWithSpan(span, () => fn(span));
|
|
781
|
+
if (span.status === "unset") span.setStatus("ok");
|
|
782
|
+
return result;
|
|
783
|
+
} catch (err) {
|
|
784
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
785
|
+
span.setStatus("error", msg);
|
|
786
|
+
throw err;
|
|
787
|
+
} finally {
|
|
788
|
+
span.end();
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Set attributes on the active request span (if any). Convenience
|
|
794
|
+
* wrapper so user code doesn't have to null-check `ctx.span`
|
|
795
|
+
* themselves.
|
|
796
|
+
*/
|
|
797
|
+
setSpanAttributes(attrs: SpanAttributes): void {
|
|
798
|
+
this.span?.setAttributes(attrs);
|
|
799
|
+
}
|
|
634
800
|
}
|
|
635
801
|
|
|
636
802
|
/** Route context for error reporting */
|
package/src/filling/filling.ts
CHANGED
|
@@ -72,8 +72,17 @@ export interface LoaderOptions<T = unknown> {
|
|
|
72
72
|
|
|
73
73
|
/** Loader 캐시/ISR 옵션 */
|
|
74
74
|
export interface LoaderCacheOptions {
|
|
75
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* Fresh 캐시 유지 시간 (초). `maxAge` 의 별칭 — Next.js `revalidate`
|
|
77
|
+
* API 와 호환성을 유지한다. 0 이면 캐시 안 함.
|
|
78
|
+
*/
|
|
76
79
|
revalidate?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Phase 18.ζ — fresh 창을 지난 뒤 캐시를 계속 서빙할 stale-while-revalidate
|
|
82
|
+
* 창 길이 (초). 지정 시 `revalidate` 구간 후 이 기간만큼 STALE 응답을
|
|
83
|
+
* 즉시 반환하고 백그라운드에서 재생성한다.
|
|
84
|
+
*/
|
|
85
|
+
staleWhileRevalidate?: number;
|
|
77
86
|
/** 온디맨드 무효화 태그 */
|
|
78
87
|
tags?: string[];
|
|
79
88
|
}
|
package/src/index.ts
CHANGED
|
@@ -38,6 +38,38 @@ export { type GuardViolation } from "./guard";
|
|
|
38
38
|
export { type Severity } from "./guard";
|
|
39
39
|
export { Image, type ImageProps } from "./components/Image";
|
|
40
40
|
|
|
41
|
+
// Phase 18.θ — `Tracer` is exported by both `runtime/trace.ts` (the
|
|
42
|
+
// legacy lifecycle trace collector) and `observability/tracing.ts` (the
|
|
43
|
+
// new OTel tracer). `export *` from both yields TS2308 ambiguity, so
|
|
44
|
+
// explicitly re-export the new one as canonical. The legacy runtime
|
|
45
|
+
// tracer remains accessible via the `runtime/trace` subpath export
|
|
46
|
+
// (`createTracer`, `TraceEvent`, etc. are unchanged).
|
|
47
|
+
export {
|
|
48
|
+
Tracer,
|
|
49
|
+
ConsoleSpanExporter,
|
|
50
|
+
OtlpHttpSpanExporter,
|
|
51
|
+
encodeOtlpJson,
|
|
52
|
+
parseTraceparent,
|
|
53
|
+
formatTraceparent,
|
|
54
|
+
newTraceId,
|
|
55
|
+
newSpanId,
|
|
56
|
+
getActiveSpan,
|
|
57
|
+
runWithSpan,
|
|
58
|
+
injectTraceContext,
|
|
59
|
+
getTracer,
|
|
60
|
+
setTracer,
|
|
61
|
+
resetTracer,
|
|
62
|
+
createTracerFromConfig,
|
|
63
|
+
type Span,
|
|
64
|
+
type SpanAttributes,
|
|
65
|
+
type SpanExporter,
|
|
66
|
+
type SpanKind,
|
|
67
|
+
type SpanOptions,
|
|
68
|
+
type SpanStatus,
|
|
69
|
+
type TraceparentFields,
|
|
70
|
+
type TracerConfig,
|
|
71
|
+
} from "./observability/tracing";
|
|
72
|
+
|
|
41
73
|
// Consolidated Mandu namespace
|
|
42
74
|
import { ManduFilling, ManduContext, ManduFillingFactory, createSSEConnection } from "./filling";
|
|
43
75
|
import { createContract, defineHandler, defineRoute, createClient, contractFetch, createClientContract, querySchema, bodySchema, apiError } from "./contract";
|
package/src/middleware/index.ts
CHANGED
|
@@ -29,6 +29,13 @@ export {
|
|
|
29
29
|
rateLimitMiddleware,
|
|
30
30
|
} from "./bridge";
|
|
31
31
|
|
|
32
|
+
export {
|
|
33
|
+
schedulerCron,
|
|
34
|
+
setActiveSchedulerRegistration,
|
|
35
|
+
getActiveSchedulerRegistration,
|
|
36
|
+
type SchedulerCronMiddlewareOptions,
|
|
37
|
+
} from "./scheduler-cron";
|
|
38
|
+
|
|
32
39
|
export { cors, type CorsMiddlewareOptions } from "./cors";
|
|
33
40
|
export { jwt, type JwtMiddlewareOptions } from "./jwt";
|
|
34
41
|
export { csrf, type CsrfMiddlewareOptions } from "./csrf";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scheduler-cron` — request-level middleware that exposes the running
|
|
3
|
+
* scheduler registration on `ctx` so handlers can inspect job status or
|
|
4
|
+
* trigger an ad-hoc tick for debugging.
|
|
5
|
+
*
|
|
6
|
+
* This is intentionally a thin bridge — the cron jobs themselves are
|
|
7
|
+
* defined with {@link import("../scheduler").defineCron} and started at
|
|
8
|
+
* `startServer()` boot time. The middleware does NOT start or stop the
|
|
9
|
+
* scheduler; its only job is to make the `CronRegistration` handle
|
|
10
|
+
* available to downstream request handlers (e.g., an observability
|
|
11
|
+
* dashboard API that wants to render `status()`).
|
|
12
|
+
*
|
|
13
|
+
* The middleware is opt-in — it's NOT part of the default chain. Add it
|
|
14
|
+
* to `mandu.config.ts` `middleware: [...]` only if you have a route that
|
|
15
|
+
* needs `ctx.scheduler`.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* // mandu.config.ts
|
|
20
|
+
* import { defineConfig } from "@mandujs/core";
|
|
21
|
+
* import { schedulerCron } from "@mandujs/core/middleware";
|
|
22
|
+
* import { jobs } from "./jobs";
|
|
23
|
+
*
|
|
24
|
+
* export default defineConfig({
|
|
25
|
+
* scheduler: { jobs },
|
|
26
|
+
* middleware: [schedulerCron()],
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { defineMiddleware } from "./define";
|
|
32
|
+
import type { Middleware } from "./define";
|
|
33
|
+
import type { CronRegistration } from "../scheduler";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Global handle to the active {@link CronRegistration}, set by
|
|
37
|
+
* `startServer()` when it boots the scheduler. `scheduler-cron` middleware
|
|
38
|
+
* reads this slot on every request so the registration is always the one
|
|
39
|
+
* actually running.
|
|
40
|
+
*
|
|
41
|
+
* We store it on `globalThis` rather than module-scope so that multiple
|
|
42
|
+
* bundles (e.g., `@mandujs/core` loaded twice in a monorepo hot-reload)
|
|
43
|
+
* still see the same handle — matches the registry pattern in
|
|
44
|
+
* `runtime/server.ts`.
|
|
45
|
+
*/
|
|
46
|
+
const GLOBAL_KEY = "__MANDU_SCHEDULER_REGISTRATION__";
|
|
47
|
+
|
|
48
|
+
interface SchedulerGlobal {
|
|
49
|
+
[GLOBAL_KEY]?: CronRegistration | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function setActiveSchedulerRegistration(reg: CronRegistration | null): void {
|
|
53
|
+
(globalThis as unknown as SchedulerGlobal)[GLOBAL_KEY] = reg;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getActiveSchedulerRegistration(): CronRegistration | null {
|
|
57
|
+
return (globalThis as unknown as SchedulerGlobal)[GLOBAL_KEY] ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SchedulerCronMiddlewareOptions {
|
|
61
|
+
/**
|
|
62
|
+
* Custom header to stamp on the response with the current scheduler job
|
|
63
|
+
* count. Useful for smoke-checking that the scheduler is running in a
|
|
64
|
+
* given environment. Default: no header is added.
|
|
65
|
+
*/
|
|
66
|
+
statusHeader?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Creates a middleware that exposes the scheduler registration via a response
|
|
71
|
+
* header and (optionally) stamps a status header. The registration itself is
|
|
72
|
+
* wired into `ctx` via the request-level composition chain — downstream
|
|
73
|
+
* code reads it with {@link getActiveSchedulerRegistration}.
|
|
74
|
+
*/
|
|
75
|
+
export function schedulerCron(options: SchedulerCronMiddlewareOptions = {}): Middleware {
|
|
76
|
+
return defineMiddleware({
|
|
77
|
+
name: "scheduler-cron",
|
|
78
|
+
async handler(_req, next) {
|
|
79
|
+
const response = await next();
|
|
80
|
+
const reg = getActiveSchedulerRegistration();
|
|
81
|
+
if (reg && options.statusHeader) {
|
|
82
|
+
const status = reg.status();
|
|
83
|
+
const jobCount = Object.keys(status).length;
|
|
84
|
+
// Clone headers to avoid mutating an immutable response body.
|
|
85
|
+
const headers = new Headers(response.headers);
|
|
86
|
+
headers.set(options.statusHeader, String(jobCount));
|
|
87
|
+
return new Response(response.body, {
|
|
88
|
+
status: response.status,
|
|
89
|
+
statusText: response.statusText,
|
|
90
|
+
headers,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return response;
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -35,3 +35,29 @@ export {
|
|
|
35
35
|
type HeapSnapshot,
|
|
36
36
|
type CacheName,
|
|
37
37
|
} from "./metrics";
|
|
38
|
+
// Phase 18.θ: OpenTelemetry-compatible request tracing
|
|
39
|
+
export {
|
|
40
|
+
Tracer,
|
|
41
|
+
ConsoleSpanExporter,
|
|
42
|
+
OtlpHttpSpanExporter,
|
|
43
|
+
encodeOtlpJson,
|
|
44
|
+
parseTraceparent,
|
|
45
|
+
formatTraceparent,
|
|
46
|
+
newTraceId,
|
|
47
|
+
newSpanId,
|
|
48
|
+
getActiveSpan,
|
|
49
|
+
runWithSpan,
|
|
50
|
+
injectTraceContext,
|
|
51
|
+
getTracer,
|
|
52
|
+
setTracer,
|
|
53
|
+
resetTracer,
|
|
54
|
+
createTracerFromConfig,
|
|
55
|
+
type Span,
|
|
56
|
+
type SpanAttributes,
|
|
57
|
+
type SpanExporter,
|
|
58
|
+
type SpanKind,
|
|
59
|
+
type SpanOptions,
|
|
60
|
+
type SpanStatus,
|
|
61
|
+
type TraceparentFields,
|
|
62
|
+
type TracerConfig,
|
|
63
|
+
} from "./tracing";
|