@harpy-js/core 0.4.7

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.
Files changed (147) hide show
  1. package/README.md +326 -0
  2. package/dist/cli.d.ts +12 -0
  3. package/dist/cli.js +53 -0
  4. package/dist/client/Link.d.ts +5 -0
  5. package/dist/client/Link.js +62 -0
  6. package/dist/client/__tests__/getActiveItemId.test.d.ts +1 -0
  7. package/dist/client/__tests__/getActiveItemId.test.js +38 -0
  8. package/dist/client/getActiveItemId.d.ts +7 -0
  9. package/dist/client/getActiveItemId.js +55 -0
  10. package/dist/client/use-i18n.d.ts +7 -0
  11. package/dist/client/use-i18n.js +64 -0
  12. package/dist/core/__tests__/component-analyzer.test.d.ts +1 -0
  13. package/dist/core/__tests__/component-analyzer.test.js +151 -0
  14. package/dist/core/__tests__/hydration-manifest.test.d.ts +1 -0
  15. package/dist/core/__tests__/hydration-manifest.test.js +211 -0
  16. package/dist/core/__tests__/jsx.engine.test.d.ts +1 -0
  17. package/dist/core/__tests__/jsx.engine.test.js +118 -0
  18. package/dist/core/app-setup.d.ts +7 -0
  19. package/dist/core/app-setup.js +79 -0
  20. package/dist/core/auto-register.module.d.ts +9 -0
  21. package/dist/core/auto-register.module.js +18 -0
  22. package/dist/core/auto-wrap-middleware.d.ts +4 -0
  23. package/dist/core/auto-wrap-middleware.js +130 -0
  24. package/dist/core/client-component-wrapper.d.ts +5 -0
  25. package/dist/core/client-component-wrapper.js +37 -0
  26. package/dist/core/client-hydration.d.ts +2 -0
  27. package/dist/core/client-hydration.js +93 -0
  28. package/dist/core/client-wrapper-browser.d.ts +2 -0
  29. package/dist/core/client-wrapper-browser.js +22 -0
  30. package/dist/core/component-analyzer.d.ts +4 -0
  31. package/dist/core/component-analyzer.js +98 -0
  32. package/dist/core/component-auto-wrapper.d.ts +2 -0
  33. package/dist/core/component-auto-wrapper.js +63 -0
  34. package/dist/core/component-client-wrapper.d.ts +4 -0
  35. package/dist/core/component-client-wrapper.js +80 -0
  36. package/dist/core/hydration-generator.d.ts +2 -0
  37. package/dist/core/hydration-generator.js +98 -0
  38. package/dist/core/hydration-manifest.d.ts +7 -0
  39. package/dist/core/hydration-manifest.js +83 -0
  40. package/dist/core/hydration.d.ts +16 -0
  41. package/dist/core/hydration.js +72 -0
  42. package/dist/core/jsx.engine.d.ts +9 -0
  43. package/dist/core/jsx.engine.js +161 -0
  44. package/dist/core/live-reload-client.js +32 -0
  45. package/dist/core/live-reload.controller.d.ts +10 -0
  46. package/dist/core/live-reload.controller.js +38 -0
  47. package/dist/core/navigation.service.d.ts +18 -0
  48. package/dist/core/navigation.service.js +206 -0
  49. package/dist/core/router.module.d.ts +2 -0
  50. package/dist/core/router.module.js +21 -0
  51. package/dist/core/static-assets.controller.d.ts +4 -0
  52. package/dist/core/static-assets.controller.js +51 -0
  53. package/dist/core/types/nav.types.d.ts +22 -0
  54. package/dist/core/types/nav.types.js +2 -0
  55. package/dist/core/views/layout.d.ts +8 -0
  56. package/dist/core/views/layout.js +35 -0
  57. package/dist/decorators/jsx.decorator.d.ts +26 -0
  58. package/dist/decorators/jsx.decorator.js +10 -0
  59. package/dist/decorators/layout.decorator.d.ts +4 -0
  60. package/dist/decorators/layout.decorator.js +29 -0
  61. package/dist/i18n/__tests__/i18n.helper.test.d.ts +1 -0
  62. package/dist/i18n/__tests__/i18n.helper.test.js +105 -0
  63. package/dist/i18n/__tests__/i18n.interceptor.test.d.ts +1 -0
  64. package/dist/i18n/__tests__/i18n.interceptor.test.js +195 -0
  65. package/dist/i18n/__tests__/i18n.module.test.d.ts +1 -0
  66. package/dist/i18n/__tests__/i18n.module.test.js +83 -0
  67. package/dist/i18n/__tests__/i18n.service.test.d.ts +1 -0
  68. package/dist/i18n/__tests__/i18n.service.test.js +109 -0
  69. package/dist/i18n/__tests__/t.test.d.ts +1 -0
  70. package/dist/i18n/__tests__/t.test.js +66 -0
  71. package/dist/i18n/i18n-module.options.d.ts +10 -0
  72. package/dist/i18n/i18n-module.options.js +4 -0
  73. package/dist/i18n/i18n-switcher.controller.d.ts +12 -0
  74. package/dist/i18n/i18n-switcher.controller.js +80 -0
  75. package/dist/i18n/i18n-types.d.ts +8 -0
  76. package/dist/i18n/i18n-types.js +2 -0
  77. package/dist/i18n/i18n.helper.d.ts +14 -0
  78. package/dist/i18n/i18n.helper.js +70 -0
  79. package/dist/i18n/i18n.interceptor.d.ts +9 -0
  80. package/dist/i18n/i18n.interceptor.js +99 -0
  81. package/dist/i18n/i18n.module.d.ts +5 -0
  82. package/dist/i18n/i18n.module.js +51 -0
  83. package/dist/i18n/i18n.service.d.ts +12 -0
  84. package/dist/i18n/i18n.service.js +61 -0
  85. package/dist/i18n/index.d.ts +10 -0
  86. package/dist/i18n/index.js +20 -0
  87. package/dist/i18n/locale.decorator.d.ts +1 -0
  88. package/dist/i18n/locale.decorator.js +8 -0
  89. package/dist/i18n/t.d.ts +3 -0
  90. package/dist/i18n/t.js +16 -0
  91. package/dist/index.d.ts +19 -0
  92. package/dist/index.js +40 -0
  93. package/package.json +79 -0
  94. package/scripts/analyze-styles.ts +124 -0
  95. package/scripts/auto-wrap-exports.ts +239 -0
  96. package/scripts/build-css.ts +38 -0
  97. package/scripts/build-hydration.ts +313 -0
  98. package/scripts/build-page-styles.ts +43 -0
  99. package/scripts/copy-assets.ts +34 -0
  100. package/scripts/dev.sh +3 -0
  101. package/scripts/dev.ts +257 -0
  102. package/src/cli.ts +71 -0
  103. package/src/client/Link.tsx +62 -0
  104. package/src/client/__tests__/getActiveItemId.test.ts +49 -0
  105. package/src/client/getActiveItemId.ts +54 -0
  106. package/src/client/use-i18n.ts +111 -0
  107. package/src/core/__tests__/component-analyzer.test.ts +141 -0
  108. package/src/core/__tests__/hydration-manifest.test.ts +223 -0
  109. package/src/core/__tests__/jsx.engine.test.ts +137 -0
  110. package/src/core/app-setup.ts +114 -0
  111. package/src/core/auto-register.module.ts +30 -0
  112. package/src/core/auto-wrap-middleware.ts +165 -0
  113. package/src/core/client-component-wrapper.ts +72 -0
  114. package/src/core/client-hydration.tsx +99 -0
  115. package/src/core/client-wrapper-browser.ts +40 -0
  116. package/src/core/component-analyzer.ts +89 -0
  117. package/src/core/component-auto-wrapper.ts +68 -0
  118. package/src/core/component-client-wrapper.ts +112 -0
  119. package/src/core/hydration-generator.ts +94 -0
  120. package/src/core/hydration-manifest.ts +79 -0
  121. package/src/core/hydration.ts +70 -0
  122. package/src/core/jsx.engine.ts +205 -0
  123. package/src/core/live-reload-client.js +32 -0
  124. package/src/core/live-reload.controller.ts +55 -0
  125. package/src/core/navigation.service.ts +257 -0
  126. package/src/core/router.module.ts +9 -0
  127. package/src/core/static-assets.controller.ts +19 -0
  128. package/src/core/types/nav.types.ts +53 -0
  129. package/src/core/views/layout.tsx +61 -0
  130. package/src/decorators/jsx.decorator.ts +49 -0
  131. package/src/decorators/layout.decorator.ts +66 -0
  132. package/src/i18n/__tests__/i18n.helper.test.ts +126 -0
  133. package/src/i18n/__tests__/i18n.interceptor.test.ts +229 -0
  134. package/src/i18n/__tests__/i18n.module.test.ts +98 -0
  135. package/src/i18n/__tests__/i18n.service.test.ts +129 -0
  136. package/src/i18n/__tests__/t.test.ts +88 -0
  137. package/src/i18n/i18n-module.options.ts +53 -0
  138. package/src/i18n/i18n-switcher.controller.ts +99 -0
  139. package/src/i18n/i18n-types.ts +56 -0
  140. package/src/i18n/i18n.helper.ts +75 -0
  141. package/src/i18n/i18n.interceptor.ts +114 -0
  142. package/src/i18n/i18n.module.ts +45 -0
  143. package/src/i18n/i18n.service.ts +95 -0
  144. package/src/i18n/index.ts +37 -0
  145. package/src/i18n/locale.decorator.ts +10 -0
  146. package/src/i18n/t.ts +62 -0
  147. package/src/index.ts +31 -0
@@ -0,0 +1,223 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+
4
+ jest.mock("fs");
5
+
6
+ describe("Hydration Manifest", () => {
7
+ const mockFs = fs as jest.Mocked<typeof fs>;
8
+
9
+ beforeEach(() => {
10
+ jest.clearAllMocks();
11
+ });
12
+
13
+ describe("manifest structure", () => {
14
+ it("should have components array", () => {
15
+ const manifest = { components: [] };
16
+ expect(manifest).toHaveProperty("components");
17
+ expect(Array.isArray(manifest.components)).toBe(true);
18
+ });
19
+
20
+ it("should store component metadata", () => {
21
+ const component = {
22
+ name: "TestComponent",
23
+ path: "/src/TestComponent.tsx",
24
+ isClient: true,
25
+ hasState: true,
26
+ hasEffects: false,
27
+ hasEventHandlers: true,
28
+ };
29
+
30
+ expect(component.name).toBe("TestComponent");
31
+ expect(component.isClient).toBe(true);
32
+ expect(component.hasState).toBe(true);
33
+ });
34
+
35
+ it("should handle multiple components", () => {
36
+ const manifest = {
37
+ components: [
38
+ { name: "Comp1", path: "/Comp1.tsx", isClient: true },
39
+ { name: "Comp2", path: "/Comp2.tsx", isClient: true },
40
+ ],
41
+ };
42
+
43
+ expect(manifest.components).toHaveLength(2);
44
+ });
45
+ });
46
+
47
+ describe("client component filtering", () => {
48
+ it("should identify client components", () => {
49
+ const components = [
50
+ { name: "ClientComp", isClient: true },
51
+ { name: "ServerComp", isClient: false },
52
+ ];
53
+
54
+ const clientOnly = components.filter((c) => c.isClient);
55
+ expect(clientOnly).toHaveLength(1);
56
+ expect(clientOnly[0].name).toBe("ClientComp");
57
+ });
58
+
59
+ it("should handle all server components", () => {
60
+ const components = [
61
+ { name: "ServerComp1", isClient: false },
62
+ { name: "ServerComp2", isClient: false },
63
+ ];
64
+
65
+ const clientOnly = components.filter((c) => c.isClient);
66
+ expect(clientOnly).toHaveLength(0);
67
+ });
68
+
69
+ it("should handle all client components", () => {
70
+ const components = [
71
+ { name: "ClientComp1", isClient: true },
72
+ { name: "ClientComp2", isClient: true },
73
+ ];
74
+
75
+ const clientOnly = components.filter((c) => c.isClient);
76
+ expect(clientOnly).toHaveLength(2);
77
+ });
78
+ });
79
+
80
+ describe("JSON serialization", () => {
81
+ it("should serialize to JSON", () => {
82
+ const manifest = {
83
+ components: [
84
+ {
85
+ name: "TestComponent",
86
+ path: "/TestComponent.tsx",
87
+ isClient: true,
88
+ hasState: true,
89
+ hasEffects: false,
90
+ hasEventHandlers: true,
91
+ },
92
+ ],
93
+ };
94
+
95
+ const json = JSON.stringify(manifest);
96
+ expect(json).toContain("TestComponent");
97
+ expect(json).toContain("isClient");
98
+ });
99
+
100
+ it("should deserialize from JSON", () => {
101
+ const json =
102
+ '{"components":[{"name":"Comp","path":"/Comp.tsx","isClient":true}]}';
103
+ const manifest = JSON.parse(json);
104
+
105
+ expect(manifest.components).toHaveLength(1);
106
+ expect(manifest.components[0].name).toBe("Comp");
107
+ });
108
+
109
+ it("should preserve component metadata", () => {
110
+ const original = {
111
+ name: "Component",
112
+ path: "/Component.tsx",
113
+ isClient: true,
114
+ hasState: true,
115
+ hasEffects: true,
116
+ hasEventHandlers: true,
117
+ };
118
+
119
+ const json = JSON.stringify(original);
120
+ const parsed = JSON.parse(json);
121
+
122
+ expect(parsed).toEqual(original);
123
+ });
124
+ });
125
+
126
+ describe("file operations", () => {
127
+ it("should serialize manifest for saving", () => {
128
+ const manifest = { components: [] };
129
+ const filePath = "/output/manifest.json";
130
+ const serialized = JSON.stringify(manifest, null, 2);
131
+
132
+ expect(serialized).toContain("components");
133
+ expect(serialized).toContain("[]");
134
+ });
135
+
136
+ it("should deserialize manifest from JSON string", () => {
137
+ const mockData = JSON.stringify({
138
+ components: [
139
+ { name: "LoadedComponent", path: "/Comp.tsx", isClient: true },
140
+ ],
141
+ });
142
+
143
+ const manifest = JSON.parse(mockData);
144
+
145
+ expect(manifest.components).toHaveLength(1);
146
+ expect(manifest.components[0].name).toBe("LoadedComponent");
147
+ });
148
+
149
+ it("should validate file paths", () => {
150
+ const validPath = "/manifest.json";
151
+ const invalidPath = "";
152
+
153
+ expect(validPath.length).toBeGreaterThan(0);
154
+ expect(invalidPath.length).toBe(0);
155
+ });
156
+
157
+ it("should handle directory paths", () => {
158
+ const dirPath = "/new/directory";
159
+ const parts = dirPath.split("/").filter(Boolean);
160
+
161
+ expect(parts).toEqual(["new", "directory"]);
162
+ expect(parts.length).toBe(2);
163
+ });
164
+ });
165
+
166
+ describe("component metadata", () => {
167
+ it("should track component state usage", () => {
168
+ const component = { name: "StatefulComp", hasState: true };
169
+ expect(component.hasState).toBe(true);
170
+ });
171
+
172
+ it("should track component effects", () => {
173
+ const component = { name: "EffectComp", hasEffects: true };
174
+ expect(component.hasEffects).toBe(true);
175
+ });
176
+
177
+ it("should track event handlers", () => {
178
+ const component = { name: "InteractiveComp", hasEventHandlers: true };
179
+ expect(component.hasEventHandlers).toBe(true);
180
+ });
181
+
182
+ it("should combine multiple flags", () => {
183
+ const component = {
184
+ name: "ComplexComp",
185
+ hasState: true,
186
+ hasEffects: true,
187
+ hasEventHandlers: true,
188
+ };
189
+
190
+ expect(component.hasState).toBe(true);
191
+ expect(component.hasEffects).toBe(true);
192
+ expect(component.hasEventHandlers).toBe(true);
193
+ });
194
+ });
195
+
196
+ describe("deduplication", () => {
197
+ it("should prevent duplicate component entries", () => {
198
+ const components = [
199
+ { name: "Comp", path: "/Comp.tsx" },
200
+ { name: "Comp", path: "/Comp.tsx" },
201
+ ];
202
+
203
+ const unique = Array.from(new Set(components.map((c) => c.name))).map(
204
+ (name) => components.find((c) => c.name === name),
205
+ );
206
+
207
+ expect(unique).toHaveLength(1);
208
+ });
209
+
210
+ it("should allow same name in different paths", () => {
211
+ const components = [
212
+ { name: "Button", path: "/ui/Button.tsx" },
213
+ { name: "Button", path: "/forms/Button.tsx" },
214
+ ];
215
+
216
+ const uniqueByPath = Array.from(
217
+ new Set(components.map((c) => c.path)),
218
+ ).map((path) => components.find((c) => c.path === path));
219
+
220
+ expect(uniqueByPath).toHaveLength(2);
221
+ });
222
+ });
223
+ });
@@ -0,0 +1,137 @@
1
+ import { withJsxEngine } from "../jsx.engine";
2
+ import { NestFastifyApplication } from "@nestjs/platform-fastify";
3
+ import * as React from "react";
4
+
5
+ describe("JSX Engine", () => {
6
+ let mockApp: NestFastifyApplication;
7
+ let mockAdapter: any;
8
+ let mockReply: any;
9
+
10
+ beforeEach(() => {
11
+ mockReply = {
12
+ raw: {
13
+ end: jest.fn(),
14
+ write: jest.fn(),
15
+ setHeader: jest.fn(),
16
+ headersSent: false,
17
+ },
18
+ statusCode: 200,
19
+ request: {},
20
+ status: jest.fn().mockReturnThis(),
21
+ send: jest.fn(),
22
+ };
23
+
24
+ mockAdapter = {
25
+ render: null,
26
+ get: jest.fn(),
27
+ post: jest.fn(),
28
+ };
29
+
30
+ mockApp = {
31
+ getHttpAdapter: jest.fn().mockReturnValue(mockAdapter),
32
+ } as unknown as NestFastifyApplication;
33
+ });
34
+
35
+ describe("withJsxEngine", () => {
36
+ it("should attach render method to the adapter", () => {
37
+ const mockLayout = (props: any) =>
38
+ React.createElement("html", null, props.children);
39
+
40
+ withJsxEngine(mockApp, mockLayout);
41
+
42
+ expect(mockAdapter.render).toBeDefined();
43
+ expect(typeof mockAdapter.render).toBe("function");
44
+ });
45
+
46
+ it("should handle redirect status codes without rendering", async () => {
47
+ const mockLayout = (props: any) =>
48
+ React.createElement("html", null, props.children);
49
+ withJsxEngine(mockApp, mockLayout);
50
+
51
+ mockReply.statusCode = 301;
52
+
53
+ await mockAdapter.render(
54
+ mockReply,
55
+ [() => React.createElement("div"), {}],
56
+ {},
57
+ );
58
+
59
+ expect(mockReply.raw.end).toHaveBeenCalled();
60
+ });
61
+
62
+ it("should handle error status codes without rendering", async () => {
63
+ const mockLayout = (props: any) =>
64
+ React.createElement("html", null, props.children);
65
+ withJsxEngine(mockApp, mockLayout);
66
+
67
+ mockReply.statusCode = 500;
68
+
69
+ await mockAdapter.render(
70
+ mockReply,
71
+ [() => React.createElement("div"), {}],
72
+ {},
73
+ );
74
+
75
+ expect(mockReply.raw.end).toHaveBeenCalled();
76
+ });
77
+
78
+ it("should accept custom layout in options", () => {
79
+ const defaultLayout = (props: any) =>
80
+ React.createElement(
81
+ "html",
82
+ null,
83
+ React.createElement("body", null, props.children),
84
+ );
85
+ const customLayout = (props: any) =>
86
+ React.createElement(
87
+ "html",
88
+ null,
89
+ React.createElement("main", null, props.children),
90
+ );
91
+
92
+ withJsxEngine(mockApp, defaultLayout);
93
+
94
+ expect(mockAdapter.render).toBeDefined();
95
+ expect(typeof customLayout).toBe("function");
96
+ });
97
+
98
+ it("should support component props", () => {
99
+ const mockLayout = (props: any) =>
100
+ React.createElement("html", null, props.children);
101
+ withJsxEngine(mockApp, mockLayout);
102
+
103
+ const component = (props: any) =>
104
+ React.createElement("div", null, `Hello ${props.name}`);
105
+ const rendered = component({ name: "Harpy" });
106
+
107
+ expect(rendered.props.children).toContain("Harpy");
108
+ });
109
+ });
110
+
111
+ describe("React rendering", () => {
112
+ it("should render simple React components", () => {
113
+ const element = React.createElement("div", null, "Hello World");
114
+ expect(element).toBeDefined();
115
+ expect(element.type).toBe("div");
116
+ });
117
+
118
+ it("should render nested components", () => {
119
+ const child = React.createElement("span", null, "Child");
120
+ const parent = React.createElement("div", null, child);
121
+
122
+ expect(parent).toBeDefined();
123
+ expect(parent.type).toBe("div");
124
+ });
125
+
126
+ it("should handle component props", () => {
127
+ const element = React.createElement(
128
+ "div",
129
+ { className: "test", id: "main" },
130
+ "Content",
131
+ );
132
+
133
+ expect(element.props.className).toBe("test");
134
+ expect(element.props.id).toBe("main");
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,114 @@
1
+ import type { NestFastifyApplication } from "@nestjs/platform-fastify";
2
+ import * as path from "path";
3
+ // Use runtime `require` for optional fastify plugins so consumers don't need
4
+ // to install them as direct dependencies of the core package at compile time.
5
+ // We'll type them as `any` to avoid TypeScript module resolution errors here.
6
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
7
+ // Load optional fastify plugins with graceful fallback so consumers that
8
+ // don't install these packages don't crash at runtime.
9
+ let fastifyStatic: any;
10
+ let fastifyCookie: any;
11
+ try {
12
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
13
+ fastifyStatic = require("@fastify/static");
14
+ } catch (e) {
15
+ // Module not installed — we'll skip registering static handler below.
16
+ fastifyStatic = undefined;
17
+ }
18
+ try {
19
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
20
+ fastifyCookie = require("@fastify/cookie");
21
+ } catch (e) {
22
+ fastifyCookie = undefined;
23
+ }
24
+ import { withJsxEngine } from "./jsx.engine";
25
+
26
+ export interface HarpyAppOptions {
27
+ /** JSX Default layout used by the app (optional) */
28
+ layout?: any;
29
+ /** Folder containing built server assets (chunks) — defaults to `dist` */
30
+ distDir?: string;
31
+ }
32
+
33
+ /**
34
+ * Configure a Nest + Fastify application with the standard Harpy defaults.
35
+ *
36
+ * This registers the JSX engine (if `layout` is provided), cookie support,
37
+ * the `dist` static handler (required for hydration chunks), and an optional
38
+ * public static handler. The function is intentionally conservative — it
39
+ * registers only what Harpy needs to function, while allowing the caller to
40
+ * pass a `publicDir` for project-specific assets.
41
+ */
42
+ export async function configureHarpyApp(
43
+ app: NestFastifyApplication,
44
+ opts: HarpyAppOptions = {},
45
+ ) {
46
+ const { layout, distDir = "dist" } = opts;
47
+
48
+ if (layout) {
49
+ withJsxEngine(app, layout);
50
+ }
51
+
52
+ const fastify = app.getHttpAdapter().getInstance();
53
+
54
+ // Cookie support is used by i18n and other helpers if available.
55
+ if (fastifyCookie) {
56
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
57
+ await fastify.register(fastifyCookie);
58
+ } else {
59
+ // If cookie plugin is not installed, warn but continue. Consumers who
60
+ // rely on cookie-based features should install `@fastify/cookie`.
61
+ // We intentionally do not throw here to avoid breaking projects that
62
+ // don't need cookie support.
63
+ // eslint-disable-next-line no-console
64
+ console.warn(
65
+ "[harpy-core] optional dependency `@fastify/cookie` is not installed; skipping cookie registration.",
66
+ );
67
+ }
68
+
69
+ // Ensure hydration chunks and other built assets are served from `dist`
70
+ // This is important: hydration chunks are expected at the root ("/").
71
+ // Use absolute path to be robust when invoked from different CWDs.
72
+ if (fastifyStatic) {
73
+ // Ensure hydration chunks and other built assets are served from `dist`
74
+ // This is important: hydration chunks are expected at the root ("/").
75
+ // Use absolute path to be robust when invoked from different CWDs.
76
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
77
+ await fastify.register(fastifyStatic, {
78
+ root: path.join(process.cwd(), distDir),
79
+ prefix: "/",
80
+ decorateReply: false,
81
+ });
82
+ } else {
83
+ // If the static plugin is not available, emit a warning and continue.
84
+ // Consumers who need hydration chunk serving in production should add
85
+ // `@fastify/static` as a dependency in their application.
86
+ // eslint-disable-next-line no-console
87
+ console.warn(
88
+ "[harpy-core] optional dependency `@fastify/static` is not installed; static `dist` handler not registered.",
89
+ );
90
+ }
91
+
92
+ // Note: we intentionally do not register a `public` static handler by
93
+ // default. Some applications prefer to control their public asset handling
94
+ // themselves (or not use a `public/` folder). If an app needs a public
95
+ // directory served under `/public/`, it can call `fastify.register` in
96
+ // its own `main.ts` after `configureHarpyApp`.
97
+
98
+ // Analytics injection is intentionally omitted — keep analytics opt-in for
99
+ // application authors so they can wire up their provider of choice.
100
+ }
101
+
102
+ /**
103
+ * A small, strongly-typed wrapper exported for consumers who may face
104
+ * editor/module-resolution issues when importing the original function.
105
+ *
106
+ * Use this in application `main.ts` to ensure the callsite is typed from
107
+ * the core package itself (avoids local casting or lint workarounds).
108
+ */
109
+ export async function setupHarpyApp(
110
+ app: NestFastifyApplication,
111
+ opts: HarpyAppOptions = {},
112
+ ) {
113
+ return configureHarpyApp(app, opts);
114
+ }
@@ -0,0 +1,30 @@
1
+ import { OnModuleInit } from "@nestjs/common";
2
+ import { NavigationService } from "./navigation.service";
3
+ import type { NavigationRegistry } from "./types/nav.types";
4
+
5
+ /**
6
+ * Base module that automatically registers navigation on module init.
7
+ *
8
+ * Feature modules can extend this class and implement `registerNavigation`
9
+ * to avoid implementing `OnModuleInit` themselves.
10
+ */
11
+ export abstract class AutoRegisterModule implements OnModuleInit {
12
+ constructor(protected readonly navigationService: NavigationService) {}
13
+
14
+ /**
15
+ * Implement this in the concrete module to register navigation items/sections.
16
+ */
17
+ protected abstract registerNavigation(navigation: NavigationRegistry): void;
18
+
19
+ onModuleInit(): void {
20
+ // Call the concrete module's registration method with the core NavigationService
21
+ try {
22
+ this.registerNavigation(this.navigationService);
23
+ } catch (err) {
24
+ // Don't let registration errors break app startup; surface via console for now.
25
+ // Consumers can still throw if they want startup to fail.
26
+ // eslint-disable-next-line no-console
27
+ console.warn("[AutoRegisterModule] registerNavigation failed:", err);
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Automatic client component wrapping middleware.
3
+ *
4
+ * This module provides a createElement wrapper that intercepts React component creation
5
+ * and automatically wraps components with 'use client' directive for hydration.
6
+ *
7
+ * It works by analyzing component modules at render time and applying hydration
8
+ * wrapping transparently without requiring explicit wrapper calls in user code.
9
+ */
10
+
11
+ import * as fs from "fs";
12
+ import * as path from "path";
13
+ import React from "react";
14
+ import { autoWrapClientComponent } from "./client-component-wrapper";
15
+ import { getComponentNameFromPath } from "./component-analyzer";
16
+
17
+ /**
18
+ * Cache of analyzed components
19
+ */
20
+ const componentAnalysisCache = new Map<
21
+ React.ComponentType<any>,
22
+ { isClientComponent: boolean; componentName: string }
23
+ >();
24
+
25
+ /**
26
+ * Try to find the source file for a component
27
+ * This is a best-effort approach that works for most cases
28
+ */
29
+ function findComponentSourceFile(
30
+ component: React.ComponentType<any>,
31
+ ): string | null {
32
+ try {
33
+ // Check if component has a source location (some bundlers preserve this)
34
+ if ((component as any).__filename) {
35
+ return (component as any).__filename;
36
+ }
37
+
38
+ // For default exports from modules, try to find based on name
39
+ // This is a heuristic and may not work in all cases
40
+ const componentName = component.displayName || component.name;
41
+ if (!componentName) return null;
42
+
43
+ // Look in src/features/*/views/ directories
44
+ const srcRoot = path.join(process.cwd(), "src");
45
+ const viewsDirs = fs.readdirSync(srcRoot).flatMap((feature) => {
46
+ const viewsPath = path.join(srcRoot, "features", feature, "views");
47
+ if (fs.existsSync(viewsPath)) {
48
+ return fs
49
+ .readdirSync(viewsPath)
50
+ .map((file) => path.join(viewsPath, file));
51
+ }
52
+ return [];
53
+ });
54
+
55
+ // Find matching file
56
+ const kebabName = componentName
57
+ .replace(/([A-Z])/g, "-$1")
58
+ .toLowerCase()
59
+ .substring(1);
60
+
61
+ return (
62
+ viewsDirs.find(
63
+ (f) =>
64
+ path.basename(f, path.extname(f)) === kebabName ||
65
+ path.basename(f, path.extname(f)) === componentName.toLowerCase(),
66
+ ) || null
67
+ );
68
+ } catch (error) {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Check if a component source has 'use client' directive
75
+ */
76
+ function hasUseClientDirective(filePath: string): boolean {
77
+ try {
78
+ const content = fs.readFileSync(filePath, "utf-8");
79
+ return /^['"]use client['"];?\s*/.test(content);
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Automatically wraps a component if it has 'use client' directive.
87
+ * Uses caching to avoid repeated file analysis.
88
+ */
89
+ export function autoWrapIfUsesClient(
90
+ component: React.ComponentType<any>,
91
+ ): React.ComponentType<any> {
92
+ // Check cache
93
+ if (componentAnalysisCache.has(component)) {
94
+ const cached = componentAnalysisCache.get(component)!;
95
+ if (cached.isClientComponent) {
96
+ return autoWrapClientComponent(component, cached.componentName);
97
+ }
98
+ return component;
99
+ }
100
+
101
+ // Try to find source file
102
+ const sourceFile = findComponentSourceFile(component);
103
+ if (!sourceFile) {
104
+ // Can't find source, cache as non-client component
105
+ componentAnalysisCache.set(component, {
106
+ isClientComponent: false,
107
+ componentName: component.displayName || component.name || "Unknown",
108
+ });
109
+ return component;
110
+ }
111
+
112
+ // Check for 'use client' directive
113
+ if (!hasUseClientDirective(sourceFile)) {
114
+ const componentName = getComponentNameFromPath(sourceFile);
115
+ componentAnalysisCache.set(component, {
116
+ isClientComponent: false,
117
+ componentName,
118
+ });
119
+ return component;
120
+ }
121
+
122
+ // It's a client component - wrap it
123
+ const componentName = getComponentNameFromPath(sourceFile);
124
+ componentAnalysisCache.set(component, {
125
+ isClientComponent: true,
126
+ componentName,
127
+ });
128
+
129
+ return autoWrapClientComponent(component, componentName);
130
+ }
131
+
132
+ /**
133
+ * Override React.createElement to auto-wrap client components.
134
+ * This is called for every JSX element during rendering.
135
+ */
136
+ export const createAutoWrapCreateElement = (
137
+ originalCreateElement: typeof React.createElement,
138
+ ) => {
139
+ return (
140
+ type: React.ElementType,
141
+ props?: Record<string, any> | null,
142
+ ...children: React.ReactNode[]
143
+ ) => {
144
+ // Only process function components
145
+ if (typeof type === "function" && !type.prototype?.isReactComponent) {
146
+ try {
147
+ // Auto-wrap if it has 'use client'
148
+ const wrappedType = autoWrapIfUsesClient(type);
149
+ return originalCreateElement(wrappedType, props, ...children);
150
+ } catch (error) {
151
+ // Fallback to original if wrapping fails
152
+ return originalCreateElement(type, props, ...children);
153
+ }
154
+ }
155
+
156
+ return originalCreateElement(type, props, ...children);
157
+ };
158
+ };
159
+
160
+ /**
161
+ * Clear the analysis cache (useful for testing or hot reload)
162
+ */
163
+ export function clearComponentAnalysisCache(): void {
164
+ componentAnalysisCache.clear();
165
+ }