@fluid-app/fluid-cli-theme-dev 0.1.21 → 0.1.23

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 (48) hide show
  1. package/README.md +14 -0
  2. package/dist/index.mjs +155 -2
  3. package/dist/index.mjs.map +1 -1
  4. package/package.json +8 -4
  5. package/.turbo/turbo-build.log +0 -16
  6. package/.turbo/turbo-typecheck.log +0 -4
  7. package/jest.config.cjs +0 -21
  8. package/jest.mocks/fluid-cli.ts +0 -33
  9. package/src/__tests__/plugin-state.test.ts +0 -186
  10. package/src/api.ts +0 -28
  11. package/src/commands/dev.ts +0 -186
  12. package/src/commands/init.ts +0 -51
  13. package/src/commands/lint.ts +0 -186
  14. package/src/commands/navigate.ts +0 -259
  15. package/src/commands/pull.ts +0 -242
  16. package/src/commands/push.ts +0 -220
  17. package/src/commands/theme.ts +0 -23
  18. package/src/index.ts +0 -12
  19. package/src/plugin-state.ts +0 -171
  20. package/src/theme/dev-server/hot-reload.ts +0 -65
  21. package/src/theme/dev-server/index.ts +0 -145
  22. package/src/theme/dev-server/proxy.ts +0 -125
  23. package/src/theme/dev-server/sse.ts +0 -43
  24. package/src/theme/dev-server/watcher.ts +0 -54
  25. package/src/theme/file.ts +0 -104
  26. package/src/theme/fluid-ignore.ts +0 -64
  27. package/src/theme/mime-type.ts +0 -45
  28. package/src/theme/root.ts +0 -54
  29. package/src/theme/syncer.ts +0 -338
  30. package/src/theme-config.ts +0 -34
  31. package/src/theme-picker.ts +0 -164
  32. package/src/workspace.ts +0 -71
  33. package/tsconfig.json +0 -10
  34. package/tsdown.config.ts +0 -19
  35. /package/{skills → dist/skills}/themes-review/SKILL.md +0 -0
  36. /package/{skills → dist/skills}/themes-review/references/blocks-vs-sections.md +0 -0
  37. /package/{skills → dist/skills}/themes-review/references/css-js-hygiene.md +0 -0
  38. /package/{skills → dist/skills}/themes-review/references/dead-code.md +0 -0
  39. /package/{skills → dist/skills}/themes-review/references/dynamism.md +0 -0
  40. /package/{skills → dist/skills}/themes-review/references/editor-attributes.md +0 -0
  41. /package/{skills → dist/skills}/themes-review/references/examples.md +0 -0
  42. /package/{skills → dist/skills}/themes-review/references/fairshare-attributes.md +0 -0
  43. /package/{skills → dist/skills}/themes-review/references/global-settings.md +0 -0
  44. /package/{skills → dist/skills}/themes-review/references/liquid-correctness.md +0 -0
  45. /package/{skills → dist/skills}/themes-review/references/navigation.md +0 -0
  46. /package/{skills → dist/skills}/themes-review/references/performance.md +0 -0
  47. /package/{skills → dist/skills}/themes-review/references/security-accessibility.md +0 -0
  48. /package/{skills → dist/skills}/themes-review/references/setting-types.md +0 -0
@@ -1,220 +0,0 @@
1
- import chalk from "chalk";
2
- import { Command } from "commander";
3
- import ora from "ora";
4
- import prompts from "prompts";
5
- import { requireToken, createApiClient } from "../api.js";
6
- import { readThemeConfig, writeThemeConfig } from "../theme-config.js";
7
- import { ThemeRoot } from "../theme/root.js";
8
- import { Syncer } from "../theme/syncer.js";
9
- import { themes } from "@fluid-app/themes-api-client";
10
- import {
11
- selectTheme,
12
- findTheme,
13
- type ApplicationTheme,
14
- } from "../theme-picker.js";
15
- import { findWorkspace, resolveThemeRootFromCwd } from "../workspace.js";
16
-
17
- /**
18
- * Detect files where the remote has changed since the last pull,
19
- * and we also have local changes (i.e. we'd overwrite someone else's work).
20
- */
21
- function detectRemoteDrift(
22
- storedChecksums: Record<string, string>,
23
- remoteChecksums: Record<string, string>,
24
- themeRoot: ThemeRoot,
25
- ): string[] {
26
- const conflicts: string[] = [];
27
- for (const [key, storedChecksum] of Object.entries(storedChecksums)) {
28
- const remoteChecksum = remoteChecksums[key];
29
- if (remoteChecksum === undefined) continue;
30
- if (remoteChecksum === storedChecksum) continue; // remote unchanged since pull
31
-
32
- // Remote changed — check if we also have this file locally (and it differs)
33
- const file = themeRoot.file(key);
34
- if (!file.exists) continue;
35
- const localChecksum = file.checksum();
36
- if (localChecksum === remoteChecksum) continue; // local matches remote already
37
-
38
- conflicts.push(key);
39
- }
40
- return conflicts;
41
- }
42
-
43
- export function createPushCommand(): Command {
44
- return new Command("push")
45
- .description("Push local theme files to a remote theme")
46
- .option("-t, --theme <name-or-id>", "Theme name or ID to push to")
47
- .option("-n, --nodelete", "Do not delete remote files missing locally")
48
- .option("-f, --force", "Skip schema validation")
49
- .option("-p, --publish", "Publish the theme after pushing")
50
- .option(
51
- "-u, --unpublished",
52
- "Create a new unpublished theme and push to it",
53
- )
54
- .option("--root <path>", "Theme root directory", ".")
55
- .action(
56
- async (opts: {
57
- theme?: string;
58
- nodelete?: boolean;
59
- force?: boolean;
60
- publish?: boolean;
61
- unpublished?: boolean;
62
- root: string;
63
- }) => {
64
- requireToken();
65
-
66
- // If no explicit --root and we're inside a workspace, resolve to the theme root
67
- let rootPath = opts.root;
68
- if (rootPath === ".") {
69
- const workspace = findWorkspace();
70
- if (workspace) {
71
- rootPath = resolveThemeRootFromCwd(workspace) ?? rootPath;
72
- }
73
- }
74
-
75
- const themeRoot = new ThemeRoot(rootPath);
76
- if (!themeRoot.isValid()) {
77
- console.error(`'${rootPath}' does not look like a theme directory.`);
78
- process.exit(1);
79
- }
80
-
81
- const api = createApiClient();
82
- const config = readThemeConfig(themeRoot.root);
83
- let theme: ApplicationTheme;
84
-
85
- if (opts.unpublished) {
86
- const { name } = await prompts(
87
- {
88
- type: "text",
89
- name: "name",
90
- message: "Name for the new theme",
91
- },
92
- { onCancel: () => process.exit(130) },
93
- );
94
- if (!name) {
95
- console.error("Theme name is required.");
96
- process.exit(1);
97
- }
98
- const body = await themes.createApplicationTheme(api, {
99
- application_theme: { name, status: "draft" },
100
- });
101
- theme = body.application_theme;
102
- console.log(
103
- `Created unpublished theme: ${theme.name} (#${theme.id})`,
104
- );
105
- } else if (opts.theme) {
106
- theme = await findTheme(api, opts.theme);
107
- } else if (config) {
108
- // Use .fluid-theme.json as the default
109
- console.log(
110
- ` Using theme from .fluid-theme.json: ${chalk.bold(config.themeName)} (#${config.themeId})`,
111
- );
112
- const body = await themes.getApplicationTheme(api, config.themeId);
113
- theme = body.application_theme;
114
- } else {
115
- theme = await selectTheme(api, "Select a theme to push to");
116
- }
117
-
118
- // Check for remote drift if we have stored checksums
119
- if (config?.checksums && !opts.force) {
120
- const driftSpinner = ora("Checking for remote changes…").start();
121
- const driftSyncer = new Syncer(api, theme.id, themeRoot);
122
- await driftSyncer.fetchChecksums();
123
- const remoteChecksums = driftSyncer.remoteChecksums();
124
- const conflicts = detectRemoteDrift(
125
- config.checksums,
126
- remoteChecksums,
127
- themeRoot,
128
- );
129
- driftSpinner.stop();
130
-
131
- if (conflicts.length > 0) {
132
- console.log(
133
- chalk.yellow(
134
- `\n⚠ ${conflicts.length} file(s) changed on remote since last pull:\n`,
135
- ),
136
- );
137
- for (const key of conflicts) {
138
- console.log(` ${key}`);
139
- }
140
- console.log();
141
-
142
- const { resolution } = await prompts(
143
- {
144
- type: "select",
145
- name: "resolution",
146
- message: "How do you want to handle this?",
147
- choices: [
148
- {
149
- title: "Push anyway (overwrite remote changes)",
150
- value: "push",
151
- },
152
- {
153
- title: "Pull first, then push",
154
- value: "pull-first",
155
- },
156
- { title: "Abort", value: "abort" },
157
- ],
158
- },
159
- { onCancel: () => process.exit(130) },
160
- );
161
-
162
- if (resolution === "abort") {
163
- console.log("Aborted.");
164
- process.exit(0);
165
- }
166
- if (resolution === "pull-first") {
167
- console.log(
168
- `Run ${chalk.cyan("fluid theme pull")} first, then push again.`,
169
- );
170
- process.exit(0);
171
- }
172
- }
173
- }
174
-
175
- const syncer = new Syncer(api, theme.id, themeRoot);
176
- const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
177
-
178
- const result = await syncer.uploadTheme({
179
- delete: !opts.nodelete,
180
- validate: !opts.force,
181
- onProgress: (d, total) => {
182
- spinner.text = `Pushing ${d}/${total} files…`;
183
- },
184
- });
185
-
186
- if (result.validationFailed) {
187
- spinner.fail(
188
- `Schema validation failed (${result.errors.length} error(s)). Use --force to skip.`,
189
- );
190
- for (const e of result.errors) console.error(` ${e}`);
191
- process.exit(1);
192
- } else if (result.errors.length) {
193
- spinner.warn(`Pushed with ${result.errors.length} error(s).`);
194
- for (const e of result.errors) console.error(` ${e}`);
195
- } else {
196
- spinner.succeed(
197
- `Pushed ${result.uploaded} file(s), deleted ${result.deleted} remote file(s).`,
198
- );
199
- }
200
-
201
- // Update stored checksums after successful push
202
- if (config) {
203
- writeThemeConfig(themeRoot.root, {
204
- ...config,
205
- checksums: syncer.remoteChecksums(),
206
- });
207
- }
208
-
209
- if (opts.publish) {
210
- const pubSpinner = ora("Publishing theme…").start();
211
- try {
212
- await themes.publishApplicationTheme(api, theme.id);
213
- pubSpinner.succeed("Theme published.");
214
- } catch (e) {
215
- pubSpinner.fail(`Publish failed: ${e}`);
216
- }
217
- }
218
- },
219
- );
220
- }
@@ -1,23 +0,0 @@
1
- import { Command } from "commander";
2
- import type { PluginContext } from "@fluid-app/fluid-cli";
3
- import { createDevCommand } from "./dev.js";
4
- import { createPushCommand } from "./push.js";
5
- import { createPullCommand } from "./pull.js";
6
- import { createLintCommand } from "./lint.js";
7
- import { createInitCommand } from "./init.js";
8
- import { createNavigateCommand } from "./navigate.js";
9
-
10
- export function registerThemeCommand(ctx: PluginContext): void {
11
- const cmd = new Command("theme").description(
12
- "Theme developer workflow — dev server, push, pull, lint, init",
13
- );
14
-
15
- cmd.addCommand(createDevCommand());
16
- cmd.addCommand(createPushCommand());
17
- cmd.addCommand(createPullCommand());
18
- cmd.addCommand(createLintCommand());
19
- cmd.addCommand(createInitCommand());
20
- cmd.addCommand(createNavigateCommand());
21
-
22
- ctx.program.addCommand(cmd);
23
- }
package/src/index.ts DELETED
@@ -1,12 +0,0 @@
1
- import type { FluidPlugin, PluginContext } from "@fluid-app/fluid-cli";
2
- import { registerThemeCommand } from "./commands/theme.js";
3
-
4
- const plugin: FluidPlugin = {
5
- name: "@fluid-app/fluid-cli-theme-dev",
6
- version: "0.1.0",
7
- register(ctx: PluginContext) {
8
- registerThemeCommand(ctx);
9
- },
10
- };
11
-
12
- export default plugin;
@@ -1,171 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { readConfig, updateConfig } from "@fluid-app/fluid-cli";
3
-
4
- export interface DevThemeRef {
5
- id: number;
6
- name: string;
7
- }
8
-
9
- interface ThemeDevState {
10
- /**
11
- * Dev themes keyed per project, so `theme dev` in one working copy never
12
- * reuses (and clobbers) another project's sandbox theme. See `devThemeKey`.
13
- * Entries are pruned once their theme directory no longer exists, so the map
14
- * can't grow without bound as projects (and one-off/temp dirs) come and go.
15
- */
16
- devThemes?: Record<string, DevThemeRef>;
17
- /** Most recently started dev theme — `navigate`'s default target. */
18
- lastDevThemeId?: number;
19
- /**
20
- * Legacy single global dev theme id. Older CLI versions stored one dev theme
21
- * here regardless of project. Read once for migration (see `getDevTheme`),
22
- * then dropped in favour of `devThemes`.
23
- */
24
- devThemeId?: number;
25
- /** Legacy companion to `devThemeId`. */
26
- devThemeName?: string;
27
- }
28
-
29
- const PLUGIN_KEY = "theme-dev";
30
-
31
- function getState(): ThemeDevState {
32
- const config = readConfig();
33
- return (config.plugins[PLUGIN_KEY] as ThemeDevState) ?? {};
34
- }
35
-
36
- /** Extract the absolute theme root from a `company:themeRoot` key. */
37
- function themeRootFromKey(key: string): string {
38
- const sep = key.indexOf(":");
39
- return sep === -1 ? key : key.slice(sep + 1);
40
- }
41
-
42
- /**
43
- * Set `key` to `theme`, dropping any entries whose theme directory no longer
44
- * exists. Tying an entry's lifetime to its directory keeps the map bounded —
45
- * abandoned/deleted projects fall out the next time `theme dev` runs anywhere.
46
- */
47
- function withDevTheme(
48
- existing: Record<string, DevThemeRef> | undefined,
49
- key: string,
50
- theme: DevThemeRef,
51
- ): Record<string, DevThemeRef> {
52
- const next: Record<string, DevThemeRef> = {};
53
- for (const [k, v] of Object.entries(existing ?? {})) {
54
- if (existsSync(themeRootFromKey(k))) next[k] = v;
55
- }
56
- next[key] = theme;
57
- return next;
58
- }
59
-
60
- /**
61
- * Stable key identifying a dev theme's owning project: the Fluid company
62
- * (subdomains are globally unique) plus the absolute theme root. Two working
63
- * copies — or the same copy pulled from two companies — get distinct keys.
64
- */
65
- export function devThemeKey(
66
- company: string | undefined,
67
- themeRoot: string,
68
- ): string {
69
- return `${company ?? "default"}:${themeRoot}`;
70
- }
71
-
72
- /**
73
- * The dev theme stored for a project key, if any. Falls back once to the legacy
74
- * global `devThemeId` (older CLI versions) and adopts it for this key — clearing
75
- * the legacy fields so a second project can't adopt the same theme and collide.
76
- */
77
- export function getDevTheme(key: string): DevThemeRef | undefined {
78
- const state = getState();
79
- const existing = state.devThemes?.[key];
80
- if (existing) return existing;
81
-
82
- if (state.devThemeId) {
83
- const migrated: DevThemeRef = {
84
- id: state.devThemeId,
85
- name: state.devThemeName ?? `Development #${state.devThemeId}`,
86
- };
87
- updateConfig((config) => {
88
- const current = (config.plugins[PLUGIN_KEY] as ThemeDevState) ?? {};
89
- const { devThemeId: _id, devThemeName: _name, ...rest } = current;
90
- return {
91
- ...config,
92
- plugins: {
93
- ...config.plugins,
94
- [PLUGIN_KEY]: {
95
- ...rest,
96
- devThemes: withDevTheme(rest.devThemes, key, migrated),
97
- lastDevThemeId: migrated.id,
98
- },
99
- },
100
- };
101
- });
102
- return migrated;
103
- }
104
-
105
- return undefined;
106
- }
107
-
108
- /** Store (or refresh) the dev theme for a project key and mark it most-recent. */
109
- export function setDevTheme(key: string, theme: DevThemeRef): void {
110
- updateConfig((config) => {
111
- const current = (config.plugins[PLUGIN_KEY] as ThemeDevState) ?? {};
112
- return {
113
- ...config,
114
- plugins: {
115
- ...config.plugins,
116
- [PLUGIN_KEY]: {
117
- ...current,
118
- devThemes: withDevTheme(current.devThemes, key, theme),
119
- lastDevThemeId: theme.id,
120
- },
121
- },
122
- };
123
- });
124
- }
125
-
126
- /** Forget a project's dev theme (it was deleted remotely or is no longer a dev theme). */
127
- export function clearDevTheme(key: string): void {
128
- updateConfig((config) => {
129
- const current = (config.plugins[PLUGIN_KEY] as ThemeDevState) ?? {};
130
- const removed = current.devThemes?.[key];
131
- if (!removed) return config;
132
- const { [key]: _removed, ...rest } = current.devThemes ?? {};
133
- const next: ThemeDevState = { ...current, devThemes: rest };
134
- // Don't leave `navigate` pointing at a theme we just forgot.
135
- if (current.lastDevThemeId === removed.id) {
136
- next.lastDevThemeId = undefined;
137
- }
138
- return {
139
- ...config,
140
- plugins: { ...config.plugins, [PLUGIN_KEY]: next },
141
- };
142
- });
143
- }
144
-
145
- /**
146
- * Mark a theme as the most recently started dev server (`navigate`'s default)
147
- * without recording it as a project's dev theme — used for the `--theme`
148
- * escape hatch, which may target an arbitrary (non-dev) theme.
149
- */
150
- export function setLastDevThemeId(id: number): void {
151
- updateConfig((config) => {
152
- const current = (config.plugins[PLUGIN_KEY] as ThemeDevState) ?? {};
153
- return {
154
- ...config,
155
- plugins: {
156
- ...config.plugins,
157
- [PLUGIN_KEY]: { ...current, lastDevThemeId: id },
158
- },
159
- };
160
- });
161
- }
162
-
163
- /**
164
- * The dev theme to target by default in `navigate` — the most recently started
165
- * dev server. Falls back to the legacy global id for users who haven't yet run
166
- * the per-project `theme dev`.
167
- */
168
- export function getLastDevThemeId(): number | undefined {
169
- const state = getState();
170
- return state.lastDevThemeId ?? state.devThemeId;
171
- }
@@ -1,65 +0,0 @@
1
- export function buildHotReloadScript(mode: "full-page" | "off"): string {
2
- return `
3
- <script>
4
- (() => {
5
- window.__FLUID_CLI_ENV__ = ${JSON.stringify({ mode })};
6
-
7
- class HotReload {
8
- static reloadMode() { return window.__FLUID_CLI_ENV__.mode; }
9
- static isActive() { return HotReload.reloadMode() !== "off"; }
10
- static setHotReloadCookie(files) {
11
- const expires = new Date(Date.now() + 3000).toUTCString();
12
- document.cookie = \`hot_reload_files=\${files.join(",")};expires=\${expires};path=/\`;
13
- }
14
- static refresh(files) {
15
- HotReload.setHotReloadCookie(files);
16
- console.log("[HotReload] Refreshing page");
17
- window.location.reload();
18
- }
19
- }
20
-
21
- class SSEClient {
22
- constructor(url, handler) {
23
- if (typeof EventSource === "undefined") {
24
- console.error("[HotReload] EventSource not supported in this browser.");
25
- return;
26
- }
27
- console.log("[HotReload] Initializing…");
28
- this.url = url;
29
- this.handler = handler;
30
- }
31
- connect() {
32
- const es = new EventSource(this.url);
33
- es.onopen = () => console.log("[HotReload] SSE connected.");
34
- es.onerror = () => {
35
- console.log("[HotReload] SSE closed. Reconnecting in 5s…");
36
- es.close();
37
- setTimeout(() => this.connect(), 5000);
38
- };
39
- es.onmessage = (msg) => {
40
- const data = JSON.parse(msg.data);
41
- if (data.reload_page) { HotReload.refresh([]); return; }
42
- this.handler(data);
43
- };
44
- }
45
- }
46
-
47
- if (HotReload.isActive()) {
48
- new SSEClient("/hot-reload", (data) => {
49
- if (data.modified) HotReload.refresh(data.modified);
50
- }).connect();
51
- }
52
- })();
53
- </script>`;
54
- }
55
-
56
- export function injectHotReload(
57
- html: string,
58
- mode: "full-page" | "off",
59
- ): string {
60
- const script = buildHotReloadScript(mode);
61
- if (html.includes("</body>")) {
62
- return html.replace("</body>", `${script}\n</body>`);
63
- }
64
- return html + script;
65
- }
@@ -1,145 +0,0 @@
1
- import http from "node:http";
2
- import { SSEStream } from "./sse.js";
3
- import { proxyRequest } from "./proxy.js";
4
- import { watchTheme } from "./watcher.js";
5
- import { Syncer } from "../syncer.js";
6
- import type { ThemeRoot } from "../root.js";
7
- import type { ApiClient } from "../../api.js";
8
-
9
- export interface DevServerOptions {
10
- host: string;
11
- port: number;
12
- reloadMode: "full-page" | "off";
13
- }
14
-
15
- export interface DevServerTheme {
16
- id: number;
17
- name: string;
18
- company: string;
19
- editorUrl?: string;
20
- }
21
-
22
- export async function startDevServer(
23
- api: ApiClient,
24
- theme: DevServerTheme,
25
- themeRoot: ThemeRoot,
26
- opts: DevServerOptions & { validate?: boolean },
27
- onReady?: (address: string) => void,
28
- ): Promise<() => void> {
29
- const sse = new SSEStream();
30
- const syncer = new Syncer(api, theme.id, themeRoot);
31
-
32
- const pendingUpdates = new Set<string>();
33
-
34
- // ── Initial sync ─────────────────────────────────────────────────────────
35
- console.log(`\nSyncing theme ${theme.name} (#${theme.id})…`);
36
- const syncResult = await syncer.uploadTheme({
37
- delete: true,
38
- validate: opts.validate,
39
- onProgress: (done, total) => {
40
- process.stdout.write(`\r Uploading ${done}/${total} files…`);
41
- },
42
- });
43
- process.stdout.write("\n");
44
- if (syncResult.validationFailed) {
45
- console.error(
46
- `\nSchema validation failed (${syncResult.errors.length} error(s)). Use --force to skip.\n`,
47
- );
48
- for (const e of syncResult.errors) console.error(` ${e}`);
49
- process.exit(1);
50
- } else if (syncResult.errors.length > 0) {
51
- for (const e of syncResult.errors) console.error(` ${e}`);
52
- }
53
-
54
- // ── File watcher ─────────────────────────────────────────────────────────
55
- const stopWatcher = watchTheme(
56
- themeRoot,
57
- async (modified, added, removed) => {
58
- const changed = [...modified, ...added];
59
-
60
- for (const file of changed) {
61
- // Validate schema on liquid files during dev (warn, don't block)
62
- if (opts.validate && file.isLiquid) {
63
- const diagnostics = file.validateSchema();
64
- for (const d of diagnostics) {
65
- const prefix =
66
- d.severity === "error" ? "Schema error" : "Schema warning";
67
- console.warn(`\n[${prefix}] ${file.relativePath}: ${d.message}`);
68
- }
69
- }
70
-
71
- pendingUpdates.add(file.relativePath);
72
- try {
73
- await syncer.uploadFile(file);
74
- } catch (e) {
75
- console.error(
76
- `\n[Watcher] Upload failed: ${file.relativePath}: ${e}`,
77
- );
78
- } finally {
79
- pendingUpdates.delete(file.relativePath);
80
- }
81
- }
82
-
83
- for (const file of removed) {
84
- try {
85
- await syncer.deleteRemoteFile(file.relativePath);
86
- } catch {
87
- // ignore
88
- }
89
- }
90
-
91
- if (removed.length > 0) {
92
- sse.broadcast(JSON.stringify({ reload_page: true }));
93
- } else if (changed.length > 0) {
94
- sse.broadcast(
95
- JSON.stringify({ modified: changed.map((f) => f.relativePath) }),
96
- );
97
- }
98
- },
99
- );
100
-
101
- // ── HTTP server ───────────────────────────────────────────────────────────
102
- const server = http.createServer(async (req, res) => {
103
- if (req.url === "/hot-reload") {
104
- sse.add(res);
105
- return;
106
- }
107
-
108
- try {
109
- await proxyRequest(req, res, {
110
- company: theme.company,
111
- themeId: theme.id,
112
- reloadMode: opts.reloadMode,
113
- pendingFiles: () =>
114
- [...pendingUpdates]
115
- .map((p) => themeRoot.file(p))
116
- .filter((f) => f.isText)
117
- .map((f) => ({
118
- relativePath: f.relativePath,
119
- read: () => f.read(),
120
- })),
121
- });
122
- } catch (e) {
123
- console.error(`[Proxy] ${req.method} ${req.url} → ${e}`);
124
- if (!res.headersSent) {
125
- res.writeHead(502);
126
- res.end("Bad Gateway");
127
- }
128
- }
129
- });
130
-
131
- await new Promise<void>((resolve, reject) => {
132
- server.listen(opts.port, opts.host, () => resolve());
133
- server.on("error", reject);
134
- });
135
-
136
- const address = `http://${opts.host}:${opts.port}`;
137
- onReady?.(address);
138
-
139
- // ── Teardown ──────────────────────────────────────────────────────────────
140
- return function stop() {
141
- sse.close();
142
- stopWatcher();
143
- server.close();
144
- };
145
- }