feat(core-events): scaffold events optional core
Scaffolded via pnpm turbo gen core-package events (generator-first, ADR-022 pre-shipped zod trace landed in docs/library-decisions/). Generator wired transpilePackages + E1/J eslint rule block. Two story-00-precedent additions so the coverage gates pass: the @vitest/coverage-v8 devDep (L0 sweep) and the honest symbols.ts exclude (mirroring core-shared/vitest.config.ts) plus its missing ALLOWED_GLOBS mirror in scripts/coverage/diff.mjs (L1 gate). No consumers wired — compile-green floor for the walking-skeleton PRD (ADR-027). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
@@ -188,6 +188,59 @@ export default [
|
||||
// Events + jobs rules are added here when @repo/core-events is scaffolded
|
||||
// via `pnpm turbo gen core-package events`.
|
||||
// <gen:events-rules>
|
||||
{
|
||||
files: ["**/*.{ts,tsx,mjs,cjs,js}"],
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector:
|
||||
"ExportNamedDeclaration[source.value=/\/events\/handlers\//]",
|
||||
message:
|
||||
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
||||
},
|
||||
{
|
||||
selector: "ExportAllDeclaration[source.value=/\/events\/handlers\//]",
|
||||
message:
|
||||
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"MemberExpression[object.type='MemberExpression'][object.object.type='Identifier'][object.object.name='payload'][object.property.type='Identifier'][object.property.name='jobs']",
|
||||
message:
|
||||
"Direct `payload.jobs.*` access is not allowed here. Use IJobQueue (from @repo/core-shared/jobs) instead. Allowed only in **/integrations/cms/jobs/** and **/core-shared/src/jobs/**.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// J — `payload.jobs.*` is allowed only in the integration layer.
|
||||
// In these paths, no-restricted-syntax is narrowed to keep E1 active but
|
||||
// drop the payload.jobs check.
|
||||
// Note: "**/core-shared/src/jobs/**" does not match from within a package-local
|
||||
// ESLint run because ESLint resolves globs relative to the config file location.
|
||||
// The pattern is kept for documentation; in practice, the PayloadJobQueue class
|
||||
// uses `this.payload.jobs.*` which the selector already ignores (it only catches
|
||||
// bare `payload.jobs.*`). Any new file added there that does use bare `payload.jobs.*`
|
||||
// would need this allowlist to be expressed as "**/jobs/payload-*" or similar.
|
||||
{
|
||||
files: ["**/integrations/cms/jobs/**", "**/core-shared/src/jobs/**"],
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector:
|
||||
"ExportNamedDeclaration[source.value=/\/events\/handlers\//]",
|
||||
message:
|
||||
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
||||
},
|
||||
{
|
||||
selector: "ExportAllDeclaration[source.value=/\/events\/handlers\//]",
|
||||
message:
|
||||
"Event handlers (events/handlers/*.handler.ts) must not be re-exported. Wire them only inside the consumer feature's bind-production / bind-dev-seed (Rule E1).",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// R2 / R1 (ADR-016) — realtime-specific ESLint rules (no-direct-socket-io,
|
||||
// no-realtime-handler-reexport) are added here when @repo/core-realtime is
|
||||
// scaffolded via `pnpm turbo gen core-package realtime`.
|
||||
|
||||
9
packages/core-events/AGENTS.md
Normal file
9
packages/core-events/AGENTS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# @repo/core-events
|
||||
|
||||
Owns the cross-feature event bus: `IEventBus`, `defineEvent`, and two implementations (`InMemoryEventBus`, `PayloadJobsEventBus`).
|
||||
|
||||
**Boundary tag:** core. May be imported by feature, core, core-composition, app. May import from core-shared, tooling.
|
||||
|
||||
**Public surface:** `IEventBus`, `EventDescriptor`, `defineEvent`, `EventHandler`, `CORE_EVENTS_SYMBOLS`, both implementations.
|
||||
|
||||
**See:** `docs/decisions/adr-015-events-and-jobs.md` (pending), `docs/guides/events-and-jobs.md` (pending), `docs/superpowers/specs/2026-05-08-events-and-jobs-design.md`.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
package: zod
|
||||
version: "^3.23.0"
|
||||
tier: core
|
||||
decision: approved
|
||||
date: 2026-05-14
|
||||
deciders: [scaffolded]
|
||||
adr: adr-015
|
||||
filter-results:
|
||||
license: MIT
|
||||
types: native
|
||||
maintenance: active
|
||||
boundary-fit: pass
|
||||
shadow-check: pass
|
||||
eu-residency: n/a
|
||||
cve-scan: clean
|
||||
named-consumer: pass
|
||||
verification-commands:
|
||||
- pnpm audit --audit-level=moderate
|
||||
- npm view zod license
|
||||
accepted-cves: []
|
||||
---
|
||||
|
||||
## Filter: license
|
||||
|
||||
MIT — on the workspace allowlist.
|
||||
|
||||
## Filter: types
|
||||
|
||||
Ships first-party TypeScript types in its distribution (`.d.ts` included).
|
||||
|
||||
## Filter: maintenance
|
||||
|
||||
Active. Regular releases by Colin McDonnell; widely adopted.
|
||||
|
||||
## Filter: boundary-fit
|
||||
|
||||
Core package. Zod is the workspace-canonical validation library locked in `core-shared` (ADR-015).
|
||||
|
||||
## Filter: shadow-check
|
||||
|
||||
Zod is already the workspace-locked validation library. No shadow.
|
||||
|
||||
## Filter: eu-residency
|
||||
|
||||
Pure computation; no network calls or vendor data transmission. n/a.
|
||||
|
||||
## Filter: cve-scan
|
||||
|
||||
No advisories at adoption time.
|
||||
|
||||
## Filter: named-consumer
|
||||
|
||||
`core-events` uses zod for event-descriptor payload schemas.
|
||||
|
||||
## Prompt: replaces
|
||||
|
||||
Nothing — zod is the pre-existing workspace validation library.
|
||||
|
||||
## Prompt: migration-cost-out
|
||||
|
||||
Mechanical: swap schema definitions at call sites. No data-format lock-in.
|
||||
|
||||
## Prompt: alternatives-considered
|
||||
|
||||
Zod is workspace-locked (see `core-shared`). A replacement would require a workspace-wide ADR; no alternative was evaluated here.
|
||||
3
packages/core-events/eslint.config.js
Normal file
3
packages/core-events/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
35
packages/core-events/package.json
Normal file
35
packages/core-events/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@repo/core-events",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"payload": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
24
packages/core-events/src/event-bus.interface.ts
Normal file
24
packages/core-events/src/event-bus.interface.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { z } from "zod";
|
||||
import type { EventBusProtocol } from "@repo/core-shared/di/bind-protocols";
|
||||
import type { EventDescriptor } from "./event-descriptor";
|
||||
|
||||
export type EventHandler<T> = (event: T) => Promise<void>;
|
||||
|
||||
export interface IEventBus extends EventBusProtocol {
|
||||
publish<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
payload: T,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Subscribe a handler. `consumerFeature` is the kebab-case name of the
|
||||
* subscribing feature (e.g., "marketing-pages"). It is unused by
|
||||
* InMemoryEventBus; PayloadJobsEventBus uses it to name the fan-out task
|
||||
* slug deterministically (`__events.<event>.<consumerFeature>`).
|
||||
*/
|
||||
subscribe<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
consumerFeature: string,
|
||||
handler: EventHandler<T>,
|
||||
): void;
|
||||
}
|
||||
24
packages/core-events/src/event-descriptor.test.ts
Normal file
24
packages/core-events/src/event-descriptor.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { defineEvent } from "@/event-descriptor";
|
||||
|
||||
describe("defineEvent", () => {
|
||||
it("returns a descriptor with name and schema", () => {
|
||||
const schema = z.object({ id: z.string() }).strict();
|
||||
const descriptor = defineEvent("test.thing.happened", schema);
|
||||
expect(descriptor.name).toBe("test.thing.happened");
|
||||
expect(descriptor.schema).toBe(schema);
|
||||
});
|
||||
|
||||
it("descriptor.schema parses valid payloads", () => {
|
||||
const schema = z.object({ id: z.string() }).strict();
|
||||
const d = defineEvent("test.evt", schema);
|
||||
expect(() => d.schema.parse({ id: "abc" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("descriptor.schema rejects invalid payloads", () => {
|
||||
const schema = z.object({ id: z.string() }).strict();
|
||||
const d = defineEvent("test.evt", schema);
|
||||
expect(() => d.schema.parse({ id: 123 })).toThrow();
|
||||
});
|
||||
});
|
||||
13
packages/core-events/src/event-descriptor.ts
Normal file
13
packages/core-events/src/event-descriptor.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
export type EventDescriptor<TName extends string, TSchema extends z.ZodType> = {
|
||||
readonly name: TName;
|
||||
readonly schema: TSchema;
|
||||
};
|
||||
|
||||
export function defineEvent<TName extends string, TSchema extends z.ZodType>(
|
||||
name: TName,
|
||||
schema: TSchema,
|
||||
): EventDescriptor<TName, TSchema> {
|
||||
return { name, schema };
|
||||
}
|
||||
53
packages/core-events/src/in-memory-event-bus.test.ts
Normal file
53
packages/core-events/src/in-memory-event-bus.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { defineEvent } from "@/event-descriptor";
|
||||
import { InMemoryEventBus } from "@/in-memory-event-bus";
|
||||
|
||||
const evt = defineEvent("test.thing", z.object({ id: z.string() }).strict());
|
||||
|
||||
describe("InMemoryEventBus", () => {
|
||||
it("validates the payload via the descriptor's schema before fanout", async () => {
|
||||
const bus = new InMemoryEventBus();
|
||||
const handler = vi.fn();
|
||||
bus.subscribe(evt, "test-consumer", handler);
|
||||
await expect(
|
||||
bus.publish(evt, { id: 123 } as unknown as { id: string }),
|
||||
).rejects.toThrow();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers to all registered handlers in parallel", async () => {
|
||||
const bus = new InMemoryEventBus();
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
bus.subscribe(evt, "consumer-a", a);
|
||||
bus.subscribe(evt, "consumer-b", b);
|
||||
await bus.publish(evt, { id: "x" });
|
||||
expect(a).toHaveBeenCalledWith({ id: "x" });
|
||||
expect(b).toHaveBeenCalledWith({ id: "x" });
|
||||
});
|
||||
|
||||
it("swallows handler errors by default (publisher's publish does not throw)", async () => {
|
||||
const bus = new InMemoryEventBus();
|
||||
bus.subscribe(evt, "boom", async () => {
|
||||
throw new Error("subscriber blew up");
|
||||
});
|
||||
await expect(bus.publish(evt, { id: "x" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rethrows the first handler error when failFast is true", async () => {
|
||||
const bus = new InMemoryEventBus({ failFast: true });
|
||||
bus.subscribe(evt, "first", async () => {
|
||||
throw new Error("first failure");
|
||||
});
|
||||
bus.subscribe(evt, "second", vi.fn());
|
||||
await expect(bus.publish(evt, { id: "x" })).rejects.toThrow(
|
||||
"first failure",
|
||||
);
|
||||
});
|
||||
|
||||
it("delivers nothing when no handlers are registered", async () => {
|
||||
const bus = new InMemoryEventBus();
|
||||
await expect(bus.publish(evt, { id: "x" })).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
42
packages/core-events/src/in-memory-event-bus.ts
Normal file
42
packages/core-events/src/in-memory-event-bus.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { z } from "zod";
|
||||
import type { EventDescriptor } from "./event-descriptor";
|
||||
import type { EventHandler, IEventBus } from "./event-bus.interface";
|
||||
|
||||
export type InMemoryEventBusOptions = {
|
||||
/** When true, rethrow the first handler error (default: false — errors swallowed). */
|
||||
failFast?: boolean;
|
||||
};
|
||||
|
||||
export class InMemoryEventBus implements IEventBus {
|
||||
private readonly handlers = new Map<string, EventHandler<unknown>[]>();
|
||||
|
||||
constructor(private readonly options: InMemoryEventBusOptions = {}) {}
|
||||
|
||||
async publish<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
payload: T,
|
||||
): Promise<void> {
|
||||
descriptor.schema.parse(payload);
|
||||
const subscribers = this.handlers.get(descriptor.name) ?? [];
|
||||
if (subscribers.length === 0) return;
|
||||
const settled = await Promise.allSettled(
|
||||
subscribers.map((h) => h(payload)),
|
||||
);
|
||||
if (this.options.failFast) {
|
||||
const failure = settled.find((s) => s.status === "rejected");
|
||||
// Only the first rejection is rethrown. Other failures are intentionally
|
||||
// dropped — `failFast` is a test-affordance, not a fault-tolerance design.
|
||||
if (failure && failure.status === "rejected") throw failure.reason;
|
||||
}
|
||||
}
|
||||
|
||||
subscribe<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
_consumerFeature: string,
|
||||
handler: EventHandler<T>,
|
||||
): void {
|
||||
const arr = this.handlers.get(descriptor.name) ?? [];
|
||||
arr.push(handler as EventHandler<unknown>);
|
||||
this.handlers.set(descriptor.name, arr);
|
||||
}
|
||||
}
|
||||
9
packages/core-events/src/index.ts
Normal file
9
packages/core-events/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type { EventDescriptor } from "./event-descriptor";
|
||||
export { defineEvent } from "./event-descriptor";
|
||||
export type { IEventBus, EventHandler } from "./event-bus.interface";
|
||||
export { CORE_EVENTS_SYMBOLS } from "./symbols";
|
||||
export {
|
||||
InMemoryEventBus,
|
||||
type InMemoryEventBusOptions,
|
||||
} from "./in-memory-event-bus";
|
||||
export { PayloadJobsEventBus } from "./payload-jobs-event-bus";
|
||||
56
packages/core-events/src/payload-jobs-event-bus.test.ts
Normal file
56
packages/core-events/src/payload-jobs-event-bus.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { defineEvent } from "@/event-descriptor";
|
||||
import { PayloadJobsEventBus } from "@/payload-jobs-event-bus";
|
||||
import type { IJobQueue } from "@repo/core-shared/jobs";
|
||||
|
||||
const evt = defineEvent(
|
||||
"auth.user.signed-up",
|
||||
z.object({ userId: z.string() }).strict(),
|
||||
);
|
||||
|
||||
function recordingQueue(): IJobQueue & {
|
||||
enqueued: { taskSlug: string; input: unknown }[];
|
||||
} {
|
||||
const enqueued: { taskSlug: string; input: unknown }[] = [];
|
||||
const q: IJobQueue = {
|
||||
async enqueue(taskSlug, input) {
|
||||
enqueued.push({ taskSlug, input });
|
||||
return { jobId: `recording-${enqueued.length}` };
|
||||
},
|
||||
};
|
||||
return Object.assign(q, { enqueued });
|
||||
}
|
||||
|
||||
describe("PayloadJobsEventBus", () => {
|
||||
it("validates the payload before enqueueing", async () => {
|
||||
const queue = recordingQueue();
|
||||
const bus = new PayloadJobsEventBus(queue);
|
||||
bus.subscribe(evt, "marketing-pages", vi.fn());
|
||||
await expect(
|
||||
bus.publish(evt, { userId: 42 } as unknown as { userId: string }),
|
||||
).rejects.toThrow();
|
||||
expect(queue.enqueued).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("enqueues one task per subscriber, naming `__events.<event>.<consumer>`", async () => {
|
||||
const queue = recordingQueue();
|
||||
const bus = new PayloadJobsEventBus(queue);
|
||||
bus.subscribe(evt, "marketing-pages", vi.fn());
|
||||
bus.subscribe(evt, "blog", vi.fn());
|
||||
await bus.publish(evt, { userId: "u1" });
|
||||
expect(queue.enqueued).toHaveLength(2);
|
||||
expect(queue.enqueued.map((e) => e.taskSlug).sort()).toEqual([
|
||||
"__events.auth.user.signed-up.blog",
|
||||
"__events.auth.user.signed-up.marketing-pages",
|
||||
]);
|
||||
expect(queue.enqueued[0]!.input).toEqual({ userId: "u1" });
|
||||
});
|
||||
|
||||
it("enqueues nothing when no subscribers are registered", async () => {
|
||||
const queue = recordingQueue();
|
||||
const bus = new PayloadJobsEventBus(queue);
|
||||
await bus.publish(evt, { userId: "u1" });
|
||||
expect(queue.enqueued).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
43
packages/core-events/src/payload-jobs-event-bus.ts
Normal file
43
packages/core-events/src/payload-jobs-event-bus.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { z } from "zod";
|
||||
import type { IJobQueue } from "@repo/core-shared/jobs";
|
||||
import type { EventDescriptor } from "./event-descriptor";
|
||||
import type { EventHandler, IEventBus } from "./event-bus.interface";
|
||||
|
||||
/**
|
||||
* Production-grade bus: for each subscriber, enqueues one Payload task per
|
||||
* `publish()` call. Subscribers register with their consumer-feature name so
|
||||
* fan-out tasks are named deterministically: `__events.<event>.<consumer>`.
|
||||
* The actual handler invocation happens inside Payload's job runner — see the
|
||||
* matching task config generated by `gen event consume` (Task 39).
|
||||
*/
|
||||
export class PayloadJobsEventBus implements IEventBus {
|
||||
private readonly subscribers = new Map<string, string[]>();
|
||||
|
||||
constructor(private readonly queue: IJobQueue) {}
|
||||
|
||||
async publish<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
payload: T,
|
||||
): Promise<void> {
|
||||
descriptor.schema.parse(payload);
|
||||
const consumers = this.subscribers.get(descriptor.name) ?? [];
|
||||
await Promise.all(
|
||||
consumers.map((consumerFeature) =>
|
||||
this.queue.enqueue(
|
||||
`__events.${descriptor.name}.${consumerFeature}`,
|
||||
payload,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
subscribe<T>(
|
||||
descriptor: EventDescriptor<string, z.ZodType<T>>,
|
||||
consumerFeature: string,
|
||||
_handler: EventHandler<T>,
|
||||
): void {
|
||||
const arr = this.subscribers.get(descriptor.name) ?? [];
|
||||
if (!arr.includes(consumerFeature)) arr.push(consumerFeature);
|
||||
this.subscribers.set(descriptor.name, arr);
|
||||
}
|
||||
}
|
||||
3
packages/core-events/src/symbols.ts
Normal file
3
packages/core-events/src/symbols.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const CORE_EVENTS_SYMBOLS = {
|
||||
IEventBus: Symbol.for("@repo/core-events/IEventBus"),
|
||||
} as const;
|
||||
12
packages/core-events/tsconfig.json
Normal file
12
packages/core-events/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4
packages/core-events/turbo.json
Normal file
4
packages/core-events/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["core"]
|
||||
}
|
||||
18
packages/core-events/vitest.config.ts
Normal file
18
packages/core-events/vitest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
|
||||
export default mergeConfig(nodeVitestConfig, {
|
||||
test: {
|
||||
coverage: {
|
||||
exclude: [
|
||||
// DI symbol constants — boilerplate, covered implicitly by bind-* tests
|
||||
// (mirrors core-shared/vitest.config.ts)
|
||||
"src/**/symbols.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user