plgg-bundle 0.0.1

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 (235) hide show
  1. package/README.md +68 -0
  2. package/bin/appAliasHook.mjs +89 -0
  3. package/bin/hook.mjs +94 -0
  4. package/bin/plgg-bundle.mjs +16 -0
  5. package/dist/Dev/fixtures/devPoc.spec.d.ts +2 -0
  6. package/dist/Dev/fixtures/devPoc.spec.d.ts.map +1 -0
  7. package/dist/Dev/fixtures/devPoc.spec.js +145 -0
  8. package/dist/Dev/fixtures/devPoc.spec.js.map +1 -0
  9. package/dist/Dev/model/Fetch.d.ts +20 -0
  10. package/dist/Dev/model/Fetch.d.ts.map +1 -0
  11. package/dist/Dev/model/Fetch.js +8 -0
  12. package/dist/Dev/model/Fetch.js.map +1 -0
  13. package/dist/Dev/model/ModuleGraph.d.ts +27 -0
  14. package/dist/Dev/model/ModuleGraph.d.ts.map +1 -0
  15. package/dist/Dev/model/ModuleGraph.js +2 -0
  16. package/dist/Dev/model/ModuleGraph.js.map +1 -0
  17. package/dist/Dev/model/Protocol.d.ts +22 -0
  18. package/dist/Dev/model/Protocol.d.ts.map +1 -0
  19. package/dist/Dev/model/Protocol.js +31 -0
  20. package/dist/Dev/model/Protocol.js.map +1 -0
  21. package/dist/Dev/node/devServer.d.ts +21 -0
  22. package/dist/Dev/node/devServer.d.ts.map +1 -0
  23. package/dist/Dev/node/devServer.js +298 -0
  24. package/dist/Dev/node/devServer.js.map +1 -0
  25. package/dist/Dev/node/httpAdapter.d.ts +19 -0
  26. package/dist/Dev/node/httpAdapter.d.ts.map +1 -0
  27. package/dist/Dev/node/httpAdapter.js +76 -0
  28. package/dist/Dev/node/httpAdapter.js.map +1 -0
  29. package/dist/Dev/node/scanGraph.d.ts +21 -0
  30. package/dist/Dev/node/scanGraph.d.ts.map +1 -0
  31. package/dist/Dev/node/scanGraph.js +145 -0
  32. package/dist/Dev/node/scanGraph.js.map +1 -0
  33. package/dist/Dev/node/watch.d.ts +10 -0
  34. package/dist/Dev/node/watch.d.ts.map +1 -0
  35. package/dist/Dev/node/watch.js +44 -0
  36. package/dist/Dev/node/watch.js.map +1 -0
  37. package/dist/Dev/usecase/allowedHost.d.ts +16 -0
  38. package/dist/Dev/usecase/allowedHost.d.ts.map +1 -0
  39. package/dist/Dev/usecase/allowedHost.js +33 -0
  40. package/dist/Dev/usecase/allowedHost.js.map +1 -0
  41. package/dist/Dev/usecase/allowedHost.spec.d.ts +2 -0
  42. package/dist/Dev/usecase/allowedHost.spec.d.ts.map +1 -0
  43. package/dist/Dev/usecase/allowedHost.spec.js +13 -0
  44. package/dist/Dev/usecase/allowedHost.spec.js.map +1 -0
  45. package/dist/Dev/usecase/buildGraph.d.ts +12 -0
  46. package/dist/Dev/usecase/buildGraph.d.ts.map +1 -0
  47. package/dist/Dev/usecase/buildGraph.js +24 -0
  48. package/dist/Dev/usecase/buildGraph.js.map +1 -0
  49. package/dist/Dev/usecase/buildGraph.spec.d.ts +2 -0
  50. package/dist/Dev/usecase/buildGraph.spec.d.ts.map +1 -0
  51. package/dist/Dev/usecase/buildGraph.spec.js +16 -0
  52. package/dist/Dev/usecase/buildGraph.spec.js.map +1 -0
  53. package/dist/Dev/usecase/decorateDevHtml.d.ts +12 -0
  54. package/dist/Dev/usecase/decorateDevHtml.d.ts.map +1 -0
  55. package/dist/Dev/usecase/decorateDevHtml.js +15 -0
  56. package/dist/Dev/usecase/decorateDevHtml.js.map +1 -0
  57. package/dist/Dev/usecase/decorateDevHtml.spec.d.ts +2 -0
  58. package/dist/Dev/usecase/decorateDevHtml.spec.d.ts.map +1 -0
  59. package/dist/Dev/usecase/decorateDevHtml.spec.js +19 -0
  60. package/dist/Dev/usecase/decorateDevHtml.spec.js.map +1 -0
  61. package/dist/Dev/usecase/invalidate.d.ts +17 -0
  62. package/dist/Dev/usecase/invalidate.d.ts.map +1 -0
  63. package/dist/Dev/usecase/invalidate.js +57 -0
  64. package/dist/Dev/usecase/invalidate.js.map +1 -0
  65. package/dist/Dev/usecase/invalidate.spec.d.ts +2 -0
  66. package/dist/Dev/usecase/invalidate.spec.d.ts.map +1 -0
  67. package/dist/Dev/usecase/invalidate.spec.js +64 -0
  68. package/dist/Dev/usecase/invalidate.spec.js.map +1 -0
  69. package/dist/Dev/usecase/parseImports.d.ts +24 -0
  70. package/dist/Dev/usecase/parseImports.d.ts.map +1 -0
  71. package/dist/Dev/usecase/parseImports.js +40 -0
  72. package/dist/Dev/usecase/parseImports.js.map +1 -0
  73. package/dist/Dev/usecase/parseImports.spec.d.ts +2 -0
  74. package/dist/Dev/usecase/parseImports.spec.d.ts.map +1 -0
  75. package/dist/Dev/usecase/parseImports.spec.js +24 -0
  76. package/dist/Dev/usecase/parseImports.spec.js.map +1 -0
  77. package/dist/Dev/usecase/reloadDecision.d.ts +20 -0
  78. package/dist/Dev/usecase/reloadDecision.d.ts.map +1 -0
  79. package/dist/Dev/usecase/reloadDecision.js +52 -0
  80. package/dist/Dev/usecase/reloadDecision.js.map +1 -0
  81. package/dist/Dev/usecase/reloadDecision.spec.d.ts +2 -0
  82. package/dist/Dev/usecase/reloadDecision.spec.d.ts.map +1 -0
  83. package/dist/Dev/usecase/reloadDecision.spec.js +17 -0
  84. package/dist/Dev/usecase/reloadDecision.spec.js.map +1 -0
  85. package/dist/domain/model/BundleConfig.d.ts +136 -0
  86. package/dist/domain/model/BundleConfig.d.ts.map +1 -0
  87. package/dist/domain/model/BundleConfig.js +18 -0
  88. package/dist/domain/model/BundleConfig.js.map +1 -0
  89. package/dist/domain/model/BundleConfig.spec.d.ts +2 -0
  90. package/dist/domain/model/BundleConfig.spec.d.ts.map +1 -0
  91. package/dist/domain/model/BundleConfig.spec.js +8 -0
  92. package/dist/domain/model/BundleConfig.spec.js.map +1 -0
  93. package/dist/domain/usecase/asBundleConfig.d.ts +17 -0
  94. package/dist/domain/usecase/asBundleConfig.d.ts.map +1 -0
  95. package/dist/domain/usecase/asBundleConfig.js +179 -0
  96. package/dist/domain/usecase/asBundleConfig.js.map +1 -0
  97. package/dist/domain/usecase/asBundleConfig.spec.d.ts +2 -0
  98. package/dist/domain/usecase/asBundleConfig.spec.d.ts.map +1 -0
  99. package/dist/domain/usecase/asBundleConfig.spec.js +119 -0
  100. package/dist/domain/usecase/asBundleConfig.spec.js.map +1 -0
  101. package/dist/domain/usecase/build.d.ts +14 -0
  102. package/dist/domain/usecase/build.d.ts.map +1 -0
  103. package/dist/domain/usecase/build.js +177 -0
  104. package/dist/domain/usecase/build.js.map +1 -0
  105. package/dist/domain/usecase/collectModules.d.ts +53 -0
  106. package/dist/domain/usecase/collectModules.d.ts.map +1 -0
  107. package/dist/domain/usecase/collectModules.js +118 -0
  108. package/dist/domain/usecase/collectModules.js.map +1 -0
  109. package/dist/domain/usecase/deriveExternal.d.ts +25 -0
  110. package/dist/domain/usecase/deriveExternal.d.ts.map +1 -0
  111. package/dist/domain/usecase/deriveExternal.js +79 -0
  112. package/dist/domain/usecase/deriveExternal.js.map +1 -0
  113. package/dist/domain/usecase/deriveExternal.spec.d.ts +2 -0
  114. package/dist/domain/usecase/deriveExternal.spec.d.ts.map +1 -0
  115. package/dist/domain/usecase/deriveExternal.spec.js +56 -0
  116. package/dist/domain/usecase/deriveExternal.spec.js.map +1 -0
  117. package/dist/domain/usecase/discoverWorkspace.d.ts +27 -0
  118. package/dist/domain/usecase/discoverWorkspace.d.ts.map +1 -0
  119. package/dist/domain/usecase/discoverWorkspace.js +116 -0
  120. package/dist/domain/usecase/discoverWorkspace.js.map +1 -0
  121. package/dist/domain/usecase/discoverWorkspace.spec.d.ts +2 -0
  122. package/dist/domain/usecase/discoverWorkspace.spec.d.ts.map +1 -0
  123. package/dist/domain/usecase/discoverWorkspace.spec.js +23 -0
  124. package/dist/domain/usecase/discoverWorkspace.spec.js.map +1 -0
  125. package/dist/domain/usecase/emitBundle.d.ts +43 -0
  126. package/dist/domain/usecase/emitBundle.d.ts.map +1 -0
  127. package/dist/domain/usecase/emitBundle.js +157 -0
  128. package/dist/domain/usecase/emitBundle.js.map +1 -0
  129. package/dist/domain/usecase/emitBundle.spec.d.ts +2 -0
  130. package/dist/domain/usecase/emitBundle.spec.d.ts.map +1 -0
  131. package/dist/domain/usecase/emitBundle.spec.js +80 -0
  132. package/dist/domain/usecase/emitBundle.spec.js.map +1 -0
  133. package/dist/domain/usecase/emitDts.d.ts +32 -0
  134. package/dist/domain/usecase/emitDts.d.ts.map +1 -0
  135. package/dist/domain/usecase/emitDts.js +99 -0
  136. package/dist/domain/usecase/emitDts.js.map +1 -0
  137. package/dist/domain/usecase/isExternal.d.ts +12 -0
  138. package/dist/domain/usecase/isExternal.d.ts.map +1 -0
  139. package/dist/domain/usecase/isExternal.js +16 -0
  140. package/dist/domain/usecase/isExternal.js.map +1 -0
  141. package/dist/domain/usecase/isExternal.spec.d.ts +2 -0
  142. package/dist/domain/usecase/isExternal.spec.d.ts.map +1 -0
  143. package/dist/domain/usecase/isExternal.spec.js +16 -0
  144. package/dist/domain/usecase/isExternal.spec.js.map +1 -0
  145. package/dist/domain/usecase/resolveSpecifier.d.ts +35 -0
  146. package/dist/domain/usecase/resolveSpecifier.d.ts.map +1 -0
  147. package/dist/domain/usecase/resolveSpecifier.js +83 -0
  148. package/dist/domain/usecase/resolveSpecifier.js.map +1 -0
  149. package/dist/domain/usecase/resolveSpecifier.spec.d.ts +2 -0
  150. package/dist/domain/usecase/resolveSpecifier.spec.d.ts.map +1 -0
  151. package/dist/domain/usecase/resolveSpecifier.spec.js +44 -0
  152. package/dist/domain/usecase/resolveSpecifier.spec.js.map +1 -0
  153. package/dist/domain/usecase/resolveWorkspaceSpecifier.d.ts +29 -0
  154. package/dist/domain/usecase/resolveWorkspaceSpecifier.d.ts.map +1 -0
  155. package/dist/domain/usecase/resolveWorkspaceSpecifier.js +89 -0
  156. package/dist/domain/usecase/resolveWorkspaceSpecifier.js.map +1 -0
  157. package/dist/domain/usecase/resolveWorkspaceSpecifier.spec.d.ts +2 -0
  158. package/dist/domain/usecase/resolveWorkspaceSpecifier.spec.d.ts.map +1 -0
  159. package/dist/domain/usecase/resolveWorkspaceSpecifier.spec.js +27 -0
  160. package/dist/domain/usecase/resolveWorkspaceSpecifier.spec.js.map +1 -0
  161. package/dist/domain/usecase/rewriteDtsAliases.d.ts +25 -0
  162. package/dist/domain/usecase/rewriteDtsAliases.d.ts.map +1 -0
  163. package/dist/domain/usecase/rewriteDtsAliases.js +71 -0
  164. package/dist/domain/usecase/rewriteDtsAliases.js.map +1 -0
  165. package/dist/domain/usecase/rewriteDtsAliases.spec.d.ts +2 -0
  166. package/dist/domain/usecase/rewriteDtsAliases.spec.d.ts.map +1 -0
  167. package/dist/domain/usecase/rewriteDtsAliases.spec.js +35 -0
  168. package/dist/domain/usecase/rewriteDtsAliases.spec.js.map +1 -0
  169. package/dist/entrypoints/cli.d.ts +2 -0
  170. package/dist/entrypoints/cli.d.ts.map +1 -0
  171. package/dist/entrypoints/cli.js +79 -0
  172. package/dist/entrypoints/cli.js.map +1 -0
  173. package/dist/index.d.ts +21 -0
  174. package/dist/index.d.ts.map +1 -0
  175. package/dist/index.js +28 -0
  176. package/dist/index.js.map +1 -0
  177. package/dist/vendors/runner.d.ts +20 -0
  178. package/dist/vendors/runner.d.ts.map +1 -0
  179. package/dist/vendors/runner.js +60 -0
  180. package/dist/vendors/runner.js.map +1 -0
  181. package/dist/vendors/runner.spec.d.ts +2 -0
  182. package/dist/vendors/runner.spec.d.ts.map +1 -0
  183. package/dist/vendors/runner.spec.js +20 -0
  184. package/dist/vendors/runner.spec.js.map +1 -0
  185. package/dist/vendors/transpiler.d.ts +19 -0
  186. package/dist/vendors/transpiler.d.ts.map +1 -0
  187. package/dist/vendors/transpiler.js +42 -0
  188. package/dist/vendors/transpiler.js.map +1 -0
  189. package/package.json +31 -0
  190. package/src/Dev/fixtures/devPoc.spec.ts +235 -0
  191. package/src/Dev/model/Fetch.ts +31 -0
  192. package/src/Dev/model/ModuleGraph.ts +30 -0
  193. package/src/Dev/model/Protocol.ts +35 -0
  194. package/src/Dev/node/devServer.ts +503 -0
  195. package/src/Dev/node/httpAdapter.ts +162 -0
  196. package/src/Dev/node/scanGraph.ts +203 -0
  197. package/src/Dev/node/watch.ts +54 -0
  198. package/src/Dev/usecase/allowedHost.spec.ts +50 -0
  199. package/src/Dev/usecase/allowedHost.ts +39 -0
  200. package/src/Dev/usecase/buildGraph.spec.ts +30 -0
  201. package/src/Dev/usecase/buildGraph.ts +31 -0
  202. package/src/Dev/usecase/decorateDevHtml.spec.ts +39 -0
  203. package/src/Dev/usecase/decorateDevHtml.ts +21 -0
  204. package/src/Dev/usecase/invalidate.spec.ts +88 -0
  205. package/src/Dev/usecase/invalidate.ts +67 -0
  206. package/src/Dev/usecase/parseImports.spec.ts +58 -0
  207. package/src/Dev/usecase/parseImports.ts +50 -0
  208. package/src/Dev/usecase/reloadDecision.spec.ts +50 -0
  209. package/src/Dev/usecase/reloadDecision.ts +65 -0
  210. package/src/domain/model/BundleConfig.spec.ts +35 -0
  211. package/src/domain/model/BundleConfig.ts +162 -0
  212. package/src/domain/usecase/asBundleConfig.spec.ts +230 -0
  213. package/src/domain/usecase/asBundleConfig.ts +257 -0
  214. package/src/domain/usecase/build.ts +259 -0
  215. package/src/domain/usecase/collectModules.ts +226 -0
  216. package/src/domain/usecase/deriveExternal.spec.ts +125 -0
  217. package/src/domain/usecase/deriveExternal.ts +108 -0
  218. package/src/domain/usecase/discoverWorkspace.spec.ts +63 -0
  219. package/src/domain/usecase/discoverWorkspace.ts +183 -0
  220. package/src/domain/usecase/emitBundle.spec.ts +162 -0
  221. package/src/domain/usecase/emitBundle.ts +215 -0
  222. package/src/domain/usecase/emitDts.ts +156 -0
  223. package/src/domain/usecase/isExternal.spec.ts +53 -0
  224. package/src/domain/usecase/isExternal.ts +20 -0
  225. package/src/domain/usecase/resolveSpecifier.spec.ts +112 -0
  226. package/src/domain/usecase/resolveSpecifier.ts +127 -0
  227. package/src/domain/usecase/resolveWorkspaceSpecifier.spec.ts +79 -0
  228. package/src/domain/usecase/resolveWorkspaceSpecifier.ts +120 -0
  229. package/src/domain/usecase/rewriteDtsAliases.spec.ts +123 -0
  230. package/src/domain/usecase/rewriteDtsAliases.ts +133 -0
  231. package/src/entrypoints/cli.ts +105 -0
  232. package/src/index.ts +28 -0
  233. package/src/vendors/runner.spec.ts +50 -0
  234. package/src/vendors/runner.ts +78 -0
  235. package/src/vendors/transpiler.ts +48 -0
@@ -0,0 +1,503 @@
1
+ import {
2
+ resolve,
3
+ isAbsolute,
4
+ join,
5
+ } from "node:path";
6
+ import { existsSync } from "node:fs";
7
+ import { pathToFileURL } from "node:url";
8
+ import { register } from "node:module";
9
+ import { type Server } from "node:http";
10
+ import { type Fetch } from "plgg-bundle/Dev/model/Fetch";
11
+ import { type ModuleGraph } from "plgg-bundle/Dev/model/ModuleGraph";
12
+ import {
13
+ RELOAD_PATH,
14
+ SSE_PRELUDE,
15
+ RELOAD_FRAME,
16
+ } from "plgg-bundle/Dev/model/Protocol";
17
+ import { isAllowedHost } from "plgg-bundle/Dev/usecase/allowedHost";
18
+ import { shouldReload } from "plgg-bundle/Dev/usecase/reloadDecision";
19
+ import { decorateDevHtml } from "plgg-bundle/Dev/usecase/decorateDevHtml";
20
+ import { type BundleConfig } from "plgg-bundle/domain/model/BundleConfig";
21
+ import {
22
+ serveFetch,
23
+ type ServeOptions,
24
+ } from "plgg-bundle/Dev/node/httpAdapter";
25
+ import { watchRoots } from "plgg-bundle/Dev/node/watch";
26
+ import {
27
+ scanGraph,
28
+ type Alias,
29
+ } from "plgg-bundle/Dev/node/scanGraph";
30
+
31
+ // The dev-server orchestrator: the ONE place the pure
32
+ // reload logic, the module-runner re-import, the node:http
33
+ // serve, the fs watcher, and the SSE registry meet. This
34
+ // is the effectful edge (in `Dev/node/`, excluded from the
35
+ // coverage threshold) — its behavior is proven end-to-end
36
+ // by the fixture PoC spec, which drives a real code edit
37
+ // through it and observes the hot-reloaded output.
38
+
39
+ /**
40
+ * A running dev server's mutable state: the live app
41
+ * {@link Fetch} a reload swaps, the current module
42
+ * version (the `?v=` a re-import busts the cache with),
43
+ * the scanned {@link ModuleGraph}, and the open SSE
44
+ * streams a reload notifies.
45
+ */
46
+ type DevState = {
47
+ appFetch: Fetch;
48
+ version: number;
49
+ graph: ModuleGraph;
50
+ readonly clients: Set<
51
+ ReadableStreamDefaultController<Uint8Array>
52
+ >;
53
+ };
54
+
55
+ /** UTF-8 encode at call time (never at module top level). */
56
+ const encodeUtf8 = (text: string): Uint8Array =>
57
+ new TextEncoder().encode(text);
58
+
59
+ /** Whether a value is a non-null object. */
60
+ const isRecord = (
61
+ v: unknown,
62
+ ): v is Record<string, unknown> =>
63
+ typeof v === "object" && v !== null;
64
+
65
+ /**
66
+ * The dev-entry factory to invoke: the module's `default`
67
+ * export, or the module itself when it has none. Stays
68
+ * `unknown` — {@link callFactory} does the callable check.
69
+ */
70
+ const defaultExport = (mod: unknown): unknown =>
71
+ isRecord(mod) && "default" in mod
72
+ ? mod["default"]
73
+ : mod;
74
+
75
+ /**
76
+ * Invoke a dev-entry factory and normalise its result to a
77
+ * {@link Fetch}. The imported value is `unknown`, so the
78
+ * boundary is validated at runtime (`typeof`/`instanceof`)
79
+ * and wrapped into a correctly-typed `Fetch` — no `as`, no
80
+ * `any` annotation. Calling a value narrowed only to
81
+ * `Function` yields `any`; feeding it through the
82
+ * `unknown`-typed {@link normalizeFetch} re-establishes
83
+ * the type at the seam.
84
+ */
85
+ const callFactory = (
86
+ factory: unknown,
87
+ ): Promise<Fetch> => {
88
+ if (typeof factory !== "function") {
89
+ throw new Error(
90
+ "dev entry must default-export a () => Fetch factory",
91
+ );
92
+ }
93
+ return Promise.resolve(factory()).then(
94
+ normalizeFetch,
95
+ );
96
+ };
97
+
98
+ /**
99
+ * Validate that a factory's result is a callable and wrap
100
+ * it as a {@link Fetch} that checks each answer is a
101
+ * `Response`.
102
+ */
103
+ const normalizeFetch = (
104
+ value: unknown,
105
+ ): Fetch => {
106
+ if (typeof value !== "function") {
107
+ throw new Error(
108
+ "dev entry factory must return a Fetch (a function)",
109
+ );
110
+ }
111
+ return (request) =>
112
+ Promise.resolve(value(request)).then(
113
+ asResponse,
114
+ );
115
+ };
116
+
117
+ /** Assert a Fetch answer is a Web `Response`. */
118
+ const asResponse = (
119
+ value: unknown,
120
+ ): Response => {
121
+ if (value instanceof Response) {
122
+ return value;
123
+ }
124
+ throw new Error(
125
+ "dev entry Fetch must resolve to a Response",
126
+ );
127
+ };
128
+
129
+ /** An Error's message, or a stringified non-Error. */
130
+ const msg = (e: unknown): string =>
131
+ e instanceof Error ? e.message : String(e);
132
+
133
+ /** dev-server console output — a deliberate side effect. */
134
+ const log = (message: string): void => {
135
+ process.stdout.write(`${message}\n`);
136
+ };
137
+
138
+ /**
139
+ * Cache-busting re-import of the dev entry at `version`,
140
+ * then run its factory to get a fresh {@link Fetch}. The
141
+ * loader hook propagates `?v=<version>` down the local
142
+ * import graph, so the whole affected subgraph
143
+ * re-evaluates — real code hot-reload.
144
+ */
145
+ const load = async (
146
+ entryUrl: string,
147
+ version: number,
148
+ ): Promise<Fetch> => {
149
+ const mod: unknown = await import(
150
+ `${entryUrl}?v=${version}`
151
+ );
152
+ return callFactory(defaultExport(mod));
153
+ };
154
+
155
+ /** The 403 a disallowed Host gets. */
156
+ const forbidden = (): Response =>
157
+ new Response("Forbidden", {
158
+ status: 403,
159
+ headers: {
160
+ "content-type":
161
+ "text/plain; charset=utf-8",
162
+ },
163
+ });
164
+
165
+ /**
166
+ * The DEV-ONLY SSE endpoint: an open `text/event-stream`
167
+ * whose controller joins the client registry and is
168
+ * removed on disconnect; a reload enqueues a frame onto it.
169
+ */
170
+ const sseResponse = (
171
+ clients: DevState["clients"],
172
+ ): Response => {
173
+ // Bridge seam: capture the controller so `start` can
174
+ // register it and `cancel` can remove the same one.
175
+ let registered:
176
+ | ReadableStreamDefaultController<Uint8Array>
177
+ | undefined;
178
+ return new Response(
179
+ new ReadableStream<Uint8Array>({
180
+ start: (controller): void => {
181
+ registered = controller;
182
+ clients.add(controller);
183
+ controller.enqueue(
184
+ encodeUtf8(SSE_PRELUDE),
185
+ );
186
+ },
187
+ cancel: (): void => {
188
+ if (registered !== undefined) {
189
+ clients.delete(registered);
190
+ }
191
+ },
192
+ }),
193
+ {
194
+ status: 200,
195
+ headers: {
196
+ "content-type": "text/event-stream",
197
+ "cache-control": "no-cache",
198
+ connection: "keep-alive",
199
+ },
200
+ },
201
+ );
202
+ };
203
+
204
+ /** Push a reload frame to every open live-reload stream. */
205
+ const notifyReload = (
206
+ clients: DevState["clients"],
207
+ ): void =>
208
+ clients.forEach((controller) =>
209
+ controller.enqueue(
210
+ encodeUtf8(RELOAD_FRAME),
211
+ ),
212
+ );
213
+
214
+ /** Whether a response carries an HTML body. */
215
+ const isHtml = (response: Response): boolean => {
216
+ const ct = response.headers.get(
217
+ "content-type",
218
+ );
219
+ return ct !== null && ct.includes("text/html");
220
+ };
221
+
222
+ /**
223
+ * Dev HTML headers: drop `content-length` (the appended
224
+ * script grew the body) and force `no-store` so a tunnel/
225
+ * CDN never serves stale inlined styles after an edit.
226
+ */
227
+ const devHtmlHeaders = (
228
+ headers: Headers,
229
+ ): Headers => {
230
+ const next = new Headers(
231
+ [...headers].filter(([key]) => {
232
+ const lower = key.toLowerCase();
233
+ return (
234
+ lower !== "content-length" &&
235
+ lower !== "cache-control"
236
+ );
237
+ }),
238
+ );
239
+ next.set("cache-control", "no-store");
240
+ return next;
241
+ };
242
+
243
+ /** Append the live-reload script to an HTML response. */
244
+ const decorateHtmlResponse = (
245
+ response: Response,
246
+ ): Promise<Response> =>
247
+ isHtml(response)
248
+ ? response
249
+ .text()
250
+ .then(
251
+ (html) =>
252
+ new Response(
253
+ decorateDevHtml(html),
254
+ {
255
+ status: response.status,
256
+ headers: devHtmlHeaders(
257
+ response.headers,
258
+ ),
259
+ },
260
+ ),
261
+ )
262
+ : Promise.resolve(response);
263
+
264
+ /**
265
+ * The wrapping dev handler: 403 a disallowed Host, answer
266
+ * the SSE route, otherwise run the live app Fetch and
267
+ * append the live-reload script to its HTML.
268
+ */
269
+ const devHandler =
270
+ (
271
+ state: DevState,
272
+ allowedHosts: ReadonlyArray<string>,
273
+ ): Fetch =>
274
+ (request: Request): Promise<Response> => {
275
+ const url = new URL(request.url);
276
+ if (!isAllowedHost(allowedHosts)(url.host)) {
277
+ return Promise.resolve(forbidden());
278
+ }
279
+ if (url.pathname === RELOAD_PATH) {
280
+ return Promise.resolve(
281
+ sseResponse(state.clients),
282
+ );
283
+ }
284
+ return state
285
+ .appFetch(request)
286
+ .then(decorateHtmlResponse);
287
+ };
288
+
289
+ /** Resolve a config value that may be absolute or root-relative. */
290
+ const under = (
291
+ root: string,
292
+ path: string,
293
+ ): string =>
294
+ isAbsolute(path)
295
+ ? path
296
+ : resolve(root, path);
297
+
298
+ /**
299
+ * Register the dev-only cross-package source resolver
300
+ * (`bin/appAliasHook.mjs`) with the alias rules, so a
301
+ * dependency's `<prefix>/*` imports load from source and
302
+ * hot-reload. Registered later than the package's own hook
303
+ * so it runs first and short-circuits on an alias hit.
304
+ */
305
+ const registerSourceAliases = (
306
+ aliases: ReadonlyArray<Alias>,
307
+ ): void => {
308
+ const hookUrl = new URL(
309
+ "../../../bin/appAliasHook.mjs",
310
+ import.meta.url,
311
+ ).href;
312
+ register(hookUrl, import.meta.url, {
313
+ data: {
314
+ aliases: aliases.map((a) => ({
315
+ prefix: a.prefix,
316
+ srcDir: a.srcDir,
317
+ })),
318
+ },
319
+ });
320
+ };
321
+
322
+ /**
323
+ * Map an fs.watch filename (relative to some watched root)
324
+ * to an absolute path by probing each root, or null when
325
+ * it matches none (a deletion) — the reload decision then
326
+ * reloads conservatively.
327
+ */
328
+ const toChanged = (
329
+ roots: ReadonlyArray<string>,
330
+ filename: string,
331
+ ): string | null => {
332
+ for (const root of roots) {
333
+ const abs = resolve(root, filename);
334
+ if (existsSync(abs)) {
335
+ return abs;
336
+ }
337
+ }
338
+ return null;
339
+ };
340
+
341
+ /**
342
+ * A single hot-reload cycle: refresh the graph, decide
343
+ * whether the change is relevant, then bump the version,
344
+ * re-import the entry (re-evaluating the changed
345
+ * subgraph), swap the live Fetch, and notify browsers. A
346
+ * failed re-import keeps the last good Fetch so a
347
+ * mid-edit save never crashes the loop.
348
+ */
349
+ const reload = async (
350
+ state: DevState,
351
+ entryUrl: string,
352
+ watchAbs: ReadonlyArray<string>,
353
+ aliases: ReadonlyArray<Alias>,
354
+ filename: string,
355
+ ): Promise<void> => {
356
+ state.graph = scanGraph(watchAbs, aliases);
357
+ const changed = toChanged(watchAbs, filename);
358
+ if (
359
+ changed !== null &&
360
+ !shouldReload(state.graph, changed)
361
+ ) {
362
+ return;
363
+ }
364
+ state.version += 1;
365
+ try {
366
+ state.appFetch = await load(
367
+ entryUrl,
368
+ state.version,
369
+ );
370
+ } catch (e) {
371
+ log(
372
+ `plgg-bundle dev: reload failed (${msg(e)}) — keeping last good build`,
373
+ );
374
+ return;
375
+ }
376
+ notifyReload(state.clients);
377
+ log(
378
+ `plgg-bundle dev: hot-reloaded (${filename})`,
379
+ );
380
+ };
381
+
382
+ /** The URL the dev server is reachable at. */
383
+ const devUrl = (port: number): string =>
384
+ `http://localhost:${port}/`;
385
+
386
+ /**
387
+ * A running dev server: the URL it is reachable at and a
388
+ * `close` that stops the listener + watchers. The CLI
389
+ * ignores `close` (the server runs for the process
390
+ * lifetime); a test uses it to shut the server down so the
391
+ * process can exit.
392
+ */
393
+ export type DevServer = Readonly<{
394
+ url: string;
395
+ close: () => void;
396
+ }>;
397
+
398
+ /** The port a listening server actually bound (honours port 0). */
399
+ const boundPort = (
400
+ server: Server,
401
+ fallback: number,
402
+ ): number => {
403
+ const addr = server.address();
404
+ return typeof addr === "object" &&
405
+ addr !== null &&
406
+ typeof addr.port === "number"
407
+ ? addr.port
408
+ : fallback;
409
+ };
410
+
411
+ /**
412
+ * Start the dev server for a config's `dev` section: load
413
+ * the app Fetch, serve it over node:http, watch the source
414
+ * roots, and hot-reload on every relevant code/content
415
+ * edit. Resolves once the listener is up, with the
416
+ * reachable URL and a `close` handle.
417
+ */
418
+ export const runDevServer = async (
419
+ config: BundleConfig,
420
+ ): Promise<DevServer> => {
421
+ const dev = config.dev;
422
+ if (dev === undefined) {
423
+ throw new Error(
424
+ "plgg-bundle dev: config has no `dev` section",
425
+ );
426
+ }
427
+ const entryUrl = pathToFileURL(
428
+ under(config.root, dev.entry),
429
+ ).href;
430
+ const watchAbs = dev.watch.map((w) =>
431
+ under(config.root, w),
432
+ );
433
+ // The app's own self-alias (source), plus any
434
+ // cross-package `sourceAliases` — the resolution rules for
435
+ // both the module graph AND the loader hook.
436
+ const aliases: ReadonlyArray<Alias> = [
437
+ {
438
+ prefix: config.alias.prefix,
439
+ srcDir: join(
440
+ config.root,
441
+ config.alias.srcRoot,
442
+ ),
443
+ },
444
+ ...dev.sourceAliases.map((a) => ({
445
+ prefix: a.prefix,
446
+ srcDir: under(config.root, a.srcDir),
447
+ })),
448
+ ];
449
+ // Register the cross-package source resolver BEFORE the
450
+ // first entry import, so a dependency's `<prefix>/*`
451
+ // loads from source and hot-reloads. Only needed when the
452
+ // app pulls another package's source (a self-contained
453
+ // app resolves everything relatively / via its own hook).
454
+ if (dev.sourceAliases.length > 0) {
455
+ registerSourceAliases(aliases);
456
+ }
457
+ const state: DevState = {
458
+ version: 1,
459
+ appFetch: await load(entryUrl, 1),
460
+ graph: scanGraph(watchAbs, aliases),
461
+ clients: new Set(),
462
+ };
463
+ const handler = devHandler(
464
+ state,
465
+ dev.allowedHosts,
466
+ );
467
+ const options: ServeOptions = {
468
+ port: dev.port,
469
+ };
470
+ const server = await new Promise<Server>(
471
+ (ready) => {
472
+ const s = serveFetch(
473
+ options,
474
+ () => handler,
475
+ () => ready(s),
476
+ )();
477
+ },
478
+ );
479
+ const url = devUrl(
480
+ boundPort(server, dev.port),
481
+ );
482
+ log(`plgg-bundle dev on ${url}`);
483
+ const watchers = watchRoots(
484
+ watchAbs,
485
+ (filename) => {
486
+ void reload(
487
+ state,
488
+ entryUrl,
489
+ watchAbs,
490
+ aliases,
491
+ filename,
492
+ );
493
+ },
494
+ );
495
+ return {
496
+ url,
497
+ close: () => {
498
+ server.closeAllConnections();
499
+ server.close();
500
+ watchers.forEach((w) => w.close());
501
+ },
502
+ };
503
+ };
@@ -0,0 +1,162 @@
1
+ import {
2
+ createServer,
3
+ type IncomingMessage,
4
+ type ServerResponse,
5
+ type Server,
6
+ } from "node:http";
7
+ import { type Fetch } from "plgg-bundle/Dev/model/Fetch";
8
+
9
+ // The node:http ⇄ Web-standard bridge for the dev server.
10
+ // A plgg-free reimplementation of plgg-server's `serve`
11
+ // (the toolchain must not import a library it builds): a
12
+ // Node request becomes a Web `Request`, the app's `Fetch`
13
+ // answers, and the Web `Response` streams back. This is
14
+ // the one platform seam — kept in `Dev/node/` and out of
15
+ // the coverage threshold, exercised by the fixture PoC.
16
+
17
+ /** How the dev server binds its node:http listener. */
18
+ export type ServeOptions = Readonly<{
19
+ port: number;
20
+ hostname?: string;
21
+ }>;
22
+
23
+ /** Buffer a request body into an ArrayBuffer. */
24
+ const collectBody = (
25
+ req: IncomingMessage,
26
+ ): Promise<ArrayBuffer> =>
27
+ new Promise((resolve, reject) => {
28
+ const chunks: Array<Buffer> = [];
29
+ req.on("data", (chunk: Buffer) => {
30
+ chunks.push(chunk);
31
+ });
32
+ req.on("end", () => {
33
+ const merged = Buffer.concat(chunks);
34
+ const copy = new ArrayBuffer(
35
+ merged.byteLength,
36
+ );
37
+ new Uint8Array(copy).set(merged);
38
+ resolve(copy);
39
+ });
40
+ req.on("error", reject);
41
+ });
42
+
43
+ /** Copy Node's incoming headers into Web Headers. */
44
+ const buildHeaders = (
45
+ raw: IncomingMessage["headers"],
46
+ ): Headers =>
47
+ new Headers(
48
+ Object.entries(raw).flatMap(
49
+ ([key, value]): Array<
50
+ [string, string]
51
+ > =>
52
+ value === undefined
53
+ ? []
54
+ : typeof value === "string"
55
+ ? [[key, value]]
56
+ : value.map(
57
+ (v): [string, string] => [
58
+ key,
59
+ v,
60
+ ],
61
+ ),
62
+ ),
63
+ );
64
+
65
+ /** Convert a Node request (+ buffered body) into a Request. */
66
+ export const toRequest = (
67
+ req: IncomingMessage,
68
+ body: ArrayBuffer,
69
+ ): Request => {
70
+ const method = req.method ?? "GET";
71
+ const host = req.headers.host ?? "localhost";
72
+ const url = `http://${host}${req.url ?? "/"}`;
73
+ return method !== "GET" &&
74
+ method !== "HEAD" &&
75
+ body.byteLength > 0
76
+ ? new Request(url, {
77
+ method,
78
+ headers: buildHeaders(req.headers),
79
+ body,
80
+ })
81
+ : new Request(url, {
82
+ method,
83
+ headers: buildHeaders(req.headers),
84
+ });
85
+ };
86
+
87
+ /** Stream a Web ReadableStream of bytes onto the Node response. */
88
+ const pump = (
89
+ reader: ReadableStreamDefaultReader<Uint8Array>,
90
+ res: ServerResponse,
91
+ ): Promise<void> =>
92
+ reader.read().then((chunk) =>
93
+ chunk.done
94
+ ? new Promise<void>((resolve) =>
95
+ res.end(() => resolve()),
96
+ )
97
+ : new Promise<void>((resolve, reject) =>
98
+ res.write(
99
+ Buffer.from(chunk.value),
100
+ (e) =>
101
+ e ? reject(e) : resolve(),
102
+ ),
103
+ ).then(() => pump(reader, res)),
104
+ );
105
+
106
+ /** Write a Web Response back onto a Node ServerResponse. */
107
+ export const writeResponse = (
108
+ res: ServerResponse,
109
+ response: Response,
110
+ ): Promise<void> => {
111
+ res.statusCode = response.status;
112
+ response.headers.forEach((value, key) =>
113
+ res.setHeader(key, value),
114
+ );
115
+ return response.body === null
116
+ ? new Promise<void>((resolve) =>
117
+ res.end(() => resolve()),
118
+ )
119
+ : pump(response.body.getReader(), res);
120
+ };
121
+
122
+ /** Last-resort 500 when the adapter itself throws. */
123
+ const writeError = (
124
+ res: ServerResponse,
125
+ ): void => {
126
+ res.statusCode = 500;
127
+ res.end("Internal Server Error");
128
+ };
129
+
130
+ /**
131
+ * Serve a {@link Fetch} over a node:http server
132
+ * (data-last). The handler is read fresh from `current()`
133
+ * per request, so the dev loop can hot-swap the live
134
+ * `Fetch` without rebinding the listener.
135
+ */
136
+ export const serveFetch =
137
+ (
138
+ options: ServeOptions,
139
+ current: () => Fetch,
140
+ onListen?: () => void,
141
+ ) =>
142
+ (): Server => {
143
+ const server = createServer((req, res) =>
144
+ collectBody(req)
145
+ .then((body) =>
146
+ current()(toRequest(req, body)),
147
+ )
148
+ .then((response) =>
149
+ writeResponse(res, response),
150
+ )
151
+ .catch(() => writeError(res)),
152
+ );
153
+ return options.hostname === undefined
154
+ ? server.listen(options.port, () =>
155
+ onListen?.(),
156
+ )
157
+ : server.listen(
158
+ options.port,
159
+ options.hostname,
160
+ () => onListen?.(),
161
+ );
162
+ };