@fluid-app/fluid-cli-theme-dev 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +18 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1240 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +43 -0
- package/src/api.ts +25 -0
- package/src/commands/dev.ts +150 -0
- package/src/commands/init.ts +51 -0
- package/src/commands/navigate.ts +159 -0
- package/src/commands/pull.ts +90 -0
- package/src/commands/push.ts +121 -0
- package/src/commands/theme.ts +21 -0
- package/src/index.ts +12 -0
- package/src/plugin-state.ts +26 -0
- package/src/theme/dev-server/hot-reload.ts +65 -0
- package/src/theme/dev-server/index.ts +125 -0
- package/src/theme/dev-server/proxy.ts +125 -0
- package/src/theme/dev-server/sse.ts +43 -0
- package/src/theme/dev-server/watcher.ts +54 -0
- package/src/theme/file.ts +68 -0
- package/src/theme/fluid-ignore.ts +64 -0
- package/src/theme/mime-type.ts +45 -0
- package/src/theme/root.ts +51 -0
- package/src/theme/syncer.ts +310 -0
- package/tsconfig.json +10 -0
- package/tsdown.config.ts +19 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import prompts from "prompts";
|
|
4
|
+
import { requireToken, createApiClient } from "../api.js";
|
|
5
|
+
import { ThemeRoot } from "../theme/root.js";
|
|
6
|
+
import { Syncer } from "../theme/syncer.js";
|
|
7
|
+
|
|
8
|
+
interface ApplicationTheme {
|
|
9
|
+
id: number;
|
|
10
|
+
name: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function selectOrFindTheme(
|
|
14
|
+
api: ReturnType<typeof createApiClient>,
|
|
15
|
+
identifier?: string,
|
|
16
|
+
): Promise<ApplicationTheme> {
|
|
17
|
+
const body = await api.get<{ application_themes: ApplicationTheme[] }>(
|
|
18
|
+
"/api/application_themes",
|
|
19
|
+
);
|
|
20
|
+
const themes = body.application_themes ?? [];
|
|
21
|
+
if (!themes.length) {
|
|
22
|
+
console.error("No themes found.");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (identifier) {
|
|
27
|
+
const found =
|
|
28
|
+
themes.find((t) => String(t.id) === identifier) ??
|
|
29
|
+
themes.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
|
|
30
|
+
if (!found) {
|
|
31
|
+
console.error(`No theme found with identifier: ${identifier}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
return found;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { id } = await prompts(
|
|
38
|
+
{
|
|
39
|
+
type: "select",
|
|
40
|
+
name: "id",
|
|
41
|
+
message: "Select a theme to pull",
|
|
42
|
+
choices: themes.map((t) => ({
|
|
43
|
+
title: `${t.name} (#${t.id})`,
|
|
44
|
+
value: t.id,
|
|
45
|
+
})),
|
|
46
|
+
},
|
|
47
|
+
{ onCancel: () => process.exit(130) },
|
|
48
|
+
);
|
|
49
|
+
if (!id) {
|
|
50
|
+
console.error("No theme selected.");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
return themes.find((t) => t.id === id)!;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createPullCommand(): Command {
|
|
57
|
+
return new Command("pull")
|
|
58
|
+
.description("Pull a remote theme to your local directory")
|
|
59
|
+
.option("-t, --theme <name-or-id>", "Theme name or ID to pull")
|
|
60
|
+
.option("-n, --nodelete", "Do not delete local files missing on remote")
|
|
61
|
+
.option("--root <path>", "Theme root directory", ".")
|
|
62
|
+
.action(
|
|
63
|
+
async (opts: { theme?: string; nodelete?: boolean; root: string }) => {
|
|
64
|
+
requireToken();
|
|
65
|
+
|
|
66
|
+
const api = createApiClient();
|
|
67
|
+
const theme = await selectOrFindTheme(api, opts.theme);
|
|
68
|
+
const themeRoot = new ThemeRoot(opts.root);
|
|
69
|
+
|
|
70
|
+
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
71
|
+
const spinner = ora(`Pulling ${theme.name} (#${theme.id})…`).start();
|
|
72
|
+
|
|
73
|
+
const result = await syncer.downloadTheme({
|
|
74
|
+
delete: !opts.nodelete,
|
|
75
|
+
onProgress: (d, total) => {
|
|
76
|
+
spinner.text = `Downloading ${d}/${total} files…`;
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (result.errors.length) {
|
|
81
|
+
spinner.warn(`Pulled with ${result.errors.length} error(s).`);
|
|
82
|
+
for (const e of result.errors) console.error(` ${e}`);
|
|
83
|
+
} else {
|
|
84
|
+
spinner.succeed(
|
|
85
|
+
`Downloaded ${result.downloaded} file(s), deleted ${result.deleted} local file(s).`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import prompts from "prompts";
|
|
4
|
+
import { requireToken, createApiClient } from "../api.js";
|
|
5
|
+
import { ThemeRoot } from "../theme/root.js";
|
|
6
|
+
import { Syncer } from "../theme/syncer.js";
|
|
7
|
+
|
|
8
|
+
interface ApplicationTheme {
|
|
9
|
+
id: number;
|
|
10
|
+
name: string;
|
|
11
|
+
company: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function selectTheme(
|
|
15
|
+
api: ReturnType<typeof createApiClient>,
|
|
16
|
+
): Promise<ApplicationTheme> {
|
|
17
|
+
const body = await api.get<{ application_themes: ApplicationTheme[] }>(
|
|
18
|
+
"/api/application_themes",
|
|
19
|
+
);
|
|
20
|
+
const themes = body.application_themes ?? [];
|
|
21
|
+
if (!themes.length) {
|
|
22
|
+
console.error("No themes found.");
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
const { id } = await prompts(
|
|
26
|
+
{
|
|
27
|
+
type: "select",
|
|
28
|
+
name: "id",
|
|
29
|
+
message: "Select a theme to push to",
|
|
30
|
+
choices: themes.map((t) => ({
|
|
31
|
+
title: `${t.name} (#${t.id})`,
|
|
32
|
+
value: t.id,
|
|
33
|
+
})),
|
|
34
|
+
},
|
|
35
|
+
{ onCancel: () => process.exit(130) },
|
|
36
|
+
);
|
|
37
|
+
if (!id) {
|
|
38
|
+
console.error("No theme selected.");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
return themes.find((t) => t.id === id)!;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function findTheme(
|
|
45
|
+
api: ReturnType<typeof createApiClient>,
|
|
46
|
+
identifier: string,
|
|
47
|
+
): Promise<ApplicationTheme> {
|
|
48
|
+
const body = await api.get<{ application_themes: ApplicationTheme[] }>(
|
|
49
|
+
"/api/application_themes",
|
|
50
|
+
);
|
|
51
|
+
const themes = body.application_themes ?? [];
|
|
52
|
+
const found =
|
|
53
|
+
themes.find((t) => String(t.id) === identifier) ??
|
|
54
|
+
themes.find((t) => t.name.toLowerCase() === identifier.toLowerCase());
|
|
55
|
+
if (!found) {
|
|
56
|
+
console.error(`No theme found with identifier: ${identifier}`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createPushCommand(): Command {
|
|
63
|
+
return new Command("push")
|
|
64
|
+
.description("Push local theme files to a remote theme")
|
|
65
|
+
.option("-t, --theme <name-or-id>", "Theme name or ID to push to")
|
|
66
|
+
.option("-n, --nodelete", "Do not delete remote files missing locally")
|
|
67
|
+
.option("-f, --force", "Skip schema validation")
|
|
68
|
+
.option("-p, --publish", "Publish the theme after pushing")
|
|
69
|
+
.option("--root <path>", "Theme root directory", ".")
|
|
70
|
+
.action(
|
|
71
|
+
async (opts: {
|
|
72
|
+
theme?: string;
|
|
73
|
+
nodelete?: boolean;
|
|
74
|
+
force?: boolean;
|
|
75
|
+
publish?: boolean;
|
|
76
|
+
root: string;
|
|
77
|
+
}) => {
|
|
78
|
+
requireToken();
|
|
79
|
+
|
|
80
|
+
const themeRoot = new ThemeRoot(opts.root);
|
|
81
|
+
if (!themeRoot.isValid()) {
|
|
82
|
+
console.error(`'${opts.root}' does not look like a theme directory.`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const api = createApiClient();
|
|
87
|
+
const theme = opts.theme
|
|
88
|
+
? await findTheme(api, opts.theme)
|
|
89
|
+
: await selectTheme(api);
|
|
90
|
+
|
|
91
|
+
const syncer = new Syncer(api, theme.id, themeRoot);
|
|
92
|
+
const spinner = ora(`Pushing to ${theme.name} (#${theme.id})…`).start();
|
|
93
|
+
|
|
94
|
+
const result = await syncer.uploadTheme({
|
|
95
|
+
delete: !opts.nodelete,
|
|
96
|
+
onProgress: (d, total) => {
|
|
97
|
+
spinner.text = `Pushing ${d}/${total} files…`;
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (result.errors.length) {
|
|
102
|
+
spinner.warn(`Pushed with ${result.errors.length} error(s).`);
|
|
103
|
+
for (const e of result.errors) console.error(` ${e}`);
|
|
104
|
+
} else {
|
|
105
|
+
spinner.succeed(
|
|
106
|
+
`Pushed ${result.uploaded} file(s), deleted ${result.deleted} remote file(s).`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (opts.publish) {
|
|
111
|
+
const pubSpinner = ora("Publishing theme…").start();
|
|
112
|
+
try {
|
|
113
|
+
await api.post(`/api/application_themes/${theme.id}/publish`);
|
|
114
|
+
pubSpinner.succeed("Theme published.");
|
|
115
|
+
} catch (e) {
|
|
116
|
+
pubSpinner.fail(`Publish failed: ${e}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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 { createInitCommand } from "./init.js";
|
|
7
|
+
import { createNavigateCommand } from "./navigate.js";
|
|
8
|
+
|
|
9
|
+
export function registerThemeCommand(ctx: PluginContext): void {
|
|
10
|
+
const cmd = new Command("theme").description(
|
|
11
|
+
"Theme developer workflow — dev server, push, pull, init",
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
cmd.addCommand(createDevCommand());
|
|
15
|
+
cmd.addCommand(createPushCommand());
|
|
16
|
+
cmd.addCommand(createPullCommand());
|
|
17
|
+
cmd.addCommand(createInitCommand());
|
|
18
|
+
cmd.addCommand(createNavigateCommand());
|
|
19
|
+
|
|
20
|
+
ctx.program.addCommand(cmd);
|
|
21
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readConfig, updateConfig } from "@fluid-app/fluid-cli";
|
|
2
|
+
|
|
3
|
+
interface ThemeDevState {
|
|
4
|
+
devThemeId?: number;
|
|
5
|
+
devThemeName?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const PLUGIN_KEY = "theme-dev";
|
|
9
|
+
|
|
10
|
+
export function getPluginState(): ThemeDevState {
|
|
11
|
+
const config = readConfig();
|
|
12
|
+
return (config.plugins[PLUGIN_KEY] as ThemeDevState) ?? {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function setPluginState(updates: Partial<ThemeDevState>): void {
|
|
16
|
+
updateConfig((config) => ({
|
|
17
|
+
...config,
|
|
18
|
+
plugins: {
|
|
19
|
+
...config.plugins,
|
|
20
|
+
[PLUGIN_KEY]: {
|
|
21
|
+
...((config.plugins[PLUGIN_KEY] as ThemeDevState) ?? {}),
|
|
22
|
+
...updates,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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,
|
|
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
|
+
await syncer.uploadTheme({
|
|
37
|
+
delete: true,
|
|
38
|
+
onProgress: (done, total) => {
|
|
39
|
+
process.stdout.write(`\r Uploading ${done}/${total} files…`);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
process.stdout.write("\n");
|
|
43
|
+
|
|
44
|
+
// ── File watcher ─────────────────────────────────────────────────────────
|
|
45
|
+
const stopWatcher = watchTheme(
|
|
46
|
+
themeRoot,
|
|
47
|
+
async (modified, added, removed) => {
|
|
48
|
+
const changed = [...modified, ...added];
|
|
49
|
+
|
|
50
|
+
for (const file of changed) {
|
|
51
|
+
pendingUpdates.add(file.relativePath);
|
|
52
|
+
try {
|
|
53
|
+
await syncer.uploadFile(file);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
console.error(
|
|
56
|
+
`\n[Watcher] Upload failed: ${file.relativePath}: ${e}`,
|
|
57
|
+
);
|
|
58
|
+
} finally {
|
|
59
|
+
pendingUpdates.delete(file.relativePath);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const file of removed) {
|
|
64
|
+
try {
|
|
65
|
+
await syncer.deleteRemoteFile(file.relativePath);
|
|
66
|
+
} catch {
|
|
67
|
+
// ignore
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (removed.length > 0) {
|
|
72
|
+
sse.broadcast(JSON.stringify({ reload_page: true }));
|
|
73
|
+
} else if (changed.length > 0) {
|
|
74
|
+
sse.broadcast(
|
|
75
|
+
JSON.stringify({ modified: changed.map((f) => f.relativePath) }),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// ── HTTP server ───────────────────────────────────────────────────────────
|
|
82
|
+
const server = http.createServer(async (req, res) => {
|
|
83
|
+
if (req.url === "/hot-reload") {
|
|
84
|
+
sse.add(res);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
await proxyRequest(req, res, {
|
|
90
|
+
company: theme.company,
|
|
91
|
+
themeId: theme.id,
|
|
92
|
+
reloadMode: opts.reloadMode,
|
|
93
|
+
pendingFiles: () =>
|
|
94
|
+
[...pendingUpdates]
|
|
95
|
+
.map((p) => themeRoot.file(p))
|
|
96
|
+
.filter((f) => f.isText)
|
|
97
|
+
.map((f) => ({
|
|
98
|
+
relativePath: f.relativePath,
|
|
99
|
+
read: () => f.read(),
|
|
100
|
+
})),
|
|
101
|
+
});
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.error(`[Proxy] ${req.method} ${req.url} → ${e}`);
|
|
104
|
+
if (!res.headersSent) {
|
|
105
|
+
res.writeHead(502);
|
|
106
|
+
res.end("Bad Gateway");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await new Promise<void>((resolve, reject) => {
|
|
112
|
+
server.listen(opts.port, opts.host, () => resolve());
|
|
113
|
+
server.on("error", reject);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const address = `http://${opts.host}:${opts.port}`;
|
|
117
|
+
onReady?.(address);
|
|
118
|
+
|
|
119
|
+
// ── Teardown ──────────────────────────────────────────────────────────────
|
|
120
|
+
return function stop() {
|
|
121
|
+
sse.close();
|
|
122
|
+
stopWatcher();
|
|
123
|
+
server.close();
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import https from "node:https";
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
|
+
import { injectHotReload } from "./hot-reload.js";
|
|
4
|
+
import { getAuthToken } from "@fluid-app/fluid-cli";
|
|
5
|
+
|
|
6
|
+
const HOP_BY_HOP = new Set([
|
|
7
|
+
"connection",
|
|
8
|
+
"keep-alive",
|
|
9
|
+
"proxy-authenticate",
|
|
10
|
+
"proxy-authorization",
|
|
11
|
+
"te",
|
|
12
|
+
"trailer",
|
|
13
|
+
"transfer-encoding",
|
|
14
|
+
"upgrade",
|
|
15
|
+
"content-security-policy",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export interface ProxyOptions {
|
|
19
|
+
company: string;
|
|
20
|
+
themeId: number;
|
|
21
|
+
reloadMode: "full-page" | "off";
|
|
22
|
+
pendingFiles?: () => Array<{ relativePath: string; read: () => string }>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function proxyRequest(
|
|
26
|
+
req: IncomingMessage,
|
|
27
|
+
res: ServerResponse,
|
|
28
|
+
opts: ProxyOptions,
|
|
29
|
+
): Promise<void> {
|
|
30
|
+
const companyHost = `${opts.company}.fluid.app`;
|
|
31
|
+
|
|
32
|
+
const headers: Record<string, string> = {};
|
|
33
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
34
|
+
if (!HOP_BY_HOP.has(k.toLowerCase()) && typeof v === "string") {
|
|
35
|
+
headers[k] = v;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
headers["host"] = companyHost;
|
|
39
|
+
headers["x-fluid-theme"] = String(opts.themeId);
|
|
40
|
+
headers["user-agent"] = "Fluid CLI";
|
|
41
|
+
headers["accept-encoding"] = "identity";
|
|
42
|
+
|
|
43
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
44
|
+
url.searchParams.set("_fd", "0");
|
|
45
|
+
url.searchParams.set("pb", "0");
|
|
46
|
+
|
|
47
|
+
const pending = opts.pendingFiles?.() ?? [];
|
|
48
|
+
const isGet = req.method === "GET" || req.method === "HEAD";
|
|
49
|
+
let method = req.method ?? "GET";
|
|
50
|
+
let body: string | Buffer | undefined;
|
|
51
|
+
|
|
52
|
+
if (pending.length > 0 && isGet) {
|
|
53
|
+
method = "POST";
|
|
54
|
+
const params = new URLSearchParams();
|
|
55
|
+
params.set("_method", req.method ?? "GET");
|
|
56
|
+
for (const f of pending) {
|
|
57
|
+
params.set(`replace_templates[${f.relativePath}]`, f.read());
|
|
58
|
+
}
|
|
59
|
+
const token = getAuthToken();
|
|
60
|
+
if (token) headers["authorization"] = `Bearer ${token}`;
|
|
61
|
+
headers["content-type"] = "application/x-www-form-urlencoded";
|
|
62
|
+
body = params.toString();
|
|
63
|
+
headers["content-length"] = String(Buffer.byteLength(body));
|
|
64
|
+
} else if (!isGet) {
|
|
65
|
+
body = await readBody(req);
|
|
66
|
+
if (body.length > 0) {
|
|
67
|
+
headers["content-length"] = String(body.length);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const options: https.RequestOptions = {
|
|
73
|
+
hostname: companyHost,
|
|
74
|
+
port: 443,
|
|
75
|
+
path: url.pathname + (url.search || ""),
|
|
76
|
+
method,
|
|
77
|
+
headers,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const proxyReq = https.request(options, (proxyRes) => {
|
|
81
|
+
const contentType = proxyRes.headers["content-type"] ?? "";
|
|
82
|
+
const isHtml = contentType.includes("text/html");
|
|
83
|
+
|
|
84
|
+
const responseHeaders: Record<string, string | string[]> = {};
|
|
85
|
+
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
86
|
+
if (!HOP_BY_HOP.has(k.toLowerCase()) && v !== undefined) {
|
|
87
|
+
responseHeaders[k] = v as string | string[];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (isHtml) {
|
|
92
|
+
const chunks: Buffer[] = [];
|
|
93
|
+
proxyRes.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
94
|
+
proxyRes.on("end", () => {
|
|
95
|
+
let html = Buffer.concat(chunks).toString("utf-8");
|
|
96
|
+
html = injectHotReload(html, opts.reloadMode);
|
|
97
|
+
responseHeaders["content-length"] = String(Buffer.byteLength(html));
|
|
98
|
+
res.writeHead(proxyRes.statusCode ?? 200, responseHeaders);
|
|
99
|
+
res.end(html);
|
|
100
|
+
resolve();
|
|
101
|
+
});
|
|
102
|
+
} else {
|
|
103
|
+
res.writeHead(proxyRes.statusCode ?? 200, responseHeaders);
|
|
104
|
+
proxyRes.pipe(res);
|
|
105
|
+
proxyRes.on("end", resolve);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
proxyReq.on("error", (err) => {
|
|
110
|
+
reject(err);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (body) proxyReq.write(body);
|
|
114
|
+
proxyReq.end();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readBody(req: IncomingMessage): Promise<Buffer> {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const chunks: Buffer[] = [];
|
|
121
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
122
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
123
|
+
req.on("error", reject);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ServerResponse } from "node:http";
|
|
2
|
+
|
|
3
|
+
export class SSEStream {
|
|
4
|
+
private responses = new Set<ServerResponse>();
|
|
5
|
+
|
|
6
|
+
add(res: ServerResponse): void {
|
|
7
|
+
res.writeHead(200, {
|
|
8
|
+
"Content-Type": "text/event-stream",
|
|
9
|
+
"Cache-Control": "no-cache",
|
|
10
|
+
Connection: "keep-alive",
|
|
11
|
+
"Access-Control-Allow-Origin": "*",
|
|
12
|
+
});
|
|
13
|
+
res.write(":\n\n");
|
|
14
|
+
this.responses.add(res);
|
|
15
|
+
res.on("close", () => this.responses.delete(res));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
broadcast(data: string): void {
|
|
19
|
+
const payload = `data: ${data}\n\n`;
|
|
20
|
+
for (const res of this.responses) {
|
|
21
|
+
try {
|
|
22
|
+
res.write(payload);
|
|
23
|
+
} catch {
|
|
24
|
+
this.responses.delete(res);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
close(): void {
|
|
30
|
+
for (const res of this.responses) {
|
|
31
|
+
try {
|
|
32
|
+
res.end();
|
|
33
|
+
} catch {
|
|
34
|
+
// ignore
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
this.responses.clear();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get size(): number {
|
|
41
|
+
return this.responses.size;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import chokidar from "chokidar";
|
|
3
|
+
import type { ThemeRoot } from "../root.js";
|
|
4
|
+
import type { ThemeFile } from "../file.js";
|
|
5
|
+
|
|
6
|
+
export type FileChangeHandler = (
|
|
7
|
+
modified: ThemeFile[],
|
|
8
|
+
added: ThemeFile[],
|
|
9
|
+
removed: ThemeFile[],
|
|
10
|
+
) => Promise<void>;
|
|
11
|
+
|
|
12
|
+
export function watchTheme(
|
|
13
|
+
root: ThemeRoot,
|
|
14
|
+
handler: FileChangeHandler,
|
|
15
|
+
): () => Promise<void> {
|
|
16
|
+
const watcher = chokidar.watch(root.root, {
|
|
17
|
+
ignoreInitial: true,
|
|
18
|
+
ignored: (filePath: string) => {
|
|
19
|
+
if (filePath.includes("node_modules")) return true;
|
|
20
|
+
try {
|
|
21
|
+
const rel = relative(root.root, filePath);
|
|
22
|
+
const basename = rel.split(/[\\/]/).pop() ?? "";
|
|
23
|
+
return basename.startsWith(".") || root.ignore.ignore(rel);
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
persistent: true,
|
|
29
|
+
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
let pending = Promise.resolve();
|
|
33
|
+
const enqueue = (fn: () => Promise<void>) => {
|
|
34
|
+
pending = pending.then(fn).catch(() => {});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
watcher.on("change", (filePath) => {
|
|
38
|
+
const rel = relative(root.root, filePath);
|
|
39
|
+
if (root.ignore.ignore(rel)) return;
|
|
40
|
+
enqueue(() => handler([root.file(filePath)], [], []));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
watcher.on("add", (filePath) => {
|
|
44
|
+
const rel = relative(root.root, filePath);
|
|
45
|
+
if (root.ignore.ignore(rel)) return;
|
|
46
|
+
enqueue(() => handler([], [root.file(filePath)], []));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
watcher.on("unlink", (filePath) => {
|
|
50
|
+
enqueue(() => handler([], [], [root.file(filePath)]));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return () => watcher.close();
|
|
54
|
+
}
|