@webstir-io/webstir 0.1.1 → 0.1.3
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/README.md +13 -0
- package/assets/deployment/docker/.dockerignore +7 -0
- package/assets/deployment/docker/Dockerfile +17 -0
- package/assets/deployment/docker/README.md +44 -0
- package/assets/deployment/docker/example.env +3 -0
- package/assets/features/client_nav/client_nav.ts +369 -264
- package/assets/features/client_nav/document_navigation.ts +344 -0
- package/assets/features/client_nav/form_enhancement.ts +275 -0
- package/assets/templates/api/src/backend/index.ts +71 -10
- package/assets/templates/api/src/backend/tsconfig.json +6 -1
- package/assets/templates/full/src/backend/index.ts +71 -10
- package/assets/templates/full/src/backend/module.ts +515 -0
- package/assets/templates/full/src/backend/tests/progressive-enhancement.test.ts +180 -0
- package/assets/templates/full/src/backend/tsconfig.json +6 -1
- package/assets/templates/full/src/frontend/app/scripts/features/client-nav.ts +574 -0
- package/assets/templates/full/src/frontend/app/scripts/features/document-navigation.ts +344 -0
- package/assets/templates/full/src/frontend/app/scripts/features/form-enhancement.ts +275 -0
- package/assets/templates/full/src/frontend/pages/home/index.css +8 -0
- package/assets/templates/full/src/frontend/pages/home/index.html +6 -1
- package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +12 -2
- package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +10 -2
- package/package.json +31 -13
- package/scripts/check-feature-projections.mjs +87 -0
- package/scripts/check-full-demo-sync.mjs +89 -0
- package/scripts/check-package-install.mjs +537 -0
- package/scripts/check-standalone-install.mjs +221 -0
- package/scripts/pack-standalone.mjs +52 -28
- package/scripts/publish.sh +9 -0
- package/scripts/run-tests.mjs +103 -0
- package/scripts/sync-assets.mjs +175 -17
- package/src/add-backend-compat.ts +628 -0
- package/src/add-backend.ts +155 -27
- package/src/add.ts +111 -4
- package/src/agent.ts +393 -0
- package/src/api-watch.ts +7 -4
- package/src/backend-inspect.ts +70 -2
- package/src/backend-runtime.ts +22 -14
- package/src/build.ts +1 -3
- package/src/bun-generated-frontend-watch.ts +209 -0
- package/src/bun-globals.d.ts +23 -0
- package/src/bun-spa-document.ts +310 -0
- package/src/bun-spa-routes.ts +159 -0
- package/src/bun-spa-watch.ts +29 -0
- package/src/bun-ssg-watch.ts +304 -0
- package/src/cli.ts +381 -50
- package/src/compile-tests.ts +37 -29
- package/src/dev-server.ts +215 -144
- package/src/doctor.ts +164 -0
- package/src/enable-assets.ts +18 -1
- package/src/enable.ts +133 -41
- package/src/execute.ts +30 -4
- package/src/external-workspace.ts +178 -0
- package/src/format.ts +296 -17
- package/src/frontend-inspect.ts +32 -0
- package/src/frontend-watch.ts +27 -102
- package/src/full-watch.ts +13 -18
- package/src/index.ts +7 -0
- package/src/init-assets.ts +41 -11
- package/src/init.ts +85 -71
- package/src/inspect.ts +112 -0
- package/src/mcp/run-cli-json.ts +46 -0
- package/src/mcp/server.ts +307 -0
- package/src/operations.ts +176 -0
- package/src/providers.ts +20 -18
- package/src/refresh.ts +29 -3
- package/src/repair.ts +110 -43
- package/src/runtime-filter.ts +41 -0
- package/src/runtime.ts +1 -1
- package/src/smoke.ts +48 -16
- package/src/test.ts +54 -16
- package/src/testing-runtime.ts +273 -0
- package/src/types.ts +1 -4
- package/src/watch-events.ts +46 -17
- package/src/watch.ts +25 -14
- package/src/workspace-lock.ts +207 -0
- package/src/workspace-watcher.ts +10 -6
- package/src/workspace.ts +4 -2
- package/src/watch-daemon-client.ts +0 -171
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { watch, type FSWatcher } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
prepareBunSpaGeneratedEntries,
|
|
5
|
+
regenerateBunSpaEntry,
|
|
6
|
+
resolveBunSpaGeneratedPagePaths,
|
|
7
|
+
resolveBunSpaEntryPaths,
|
|
8
|
+
resolveBunSpaPages,
|
|
9
|
+
type BunSpaEntryPaths,
|
|
10
|
+
type BunSpaPageDetails,
|
|
11
|
+
} from './bun-spa-document.ts';
|
|
12
|
+
import {
|
|
13
|
+
createBunFrontendFetchHandler,
|
|
14
|
+
createBunSpaRoutes,
|
|
15
|
+
type BunSpaRouteEntry,
|
|
16
|
+
loadBunSpaEntry,
|
|
17
|
+
type ReloadableServeServer,
|
|
18
|
+
} from './bun-spa-routes.ts';
|
|
19
|
+
import type { DevServerAddress } from './dev-server.ts';
|
|
20
|
+
|
|
21
|
+
export interface BunGeneratedFrontendWatchOptions {
|
|
22
|
+
readonly workspaceRoot: string;
|
|
23
|
+
readonly host?: string;
|
|
24
|
+
readonly port?: number;
|
|
25
|
+
readonly apiProxyOrigin?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BunGeneratedFrontendWatchSession {
|
|
29
|
+
readonly address: DevServerAddress;
|
|
30
|
+
waitForExit(): Promise<number | null>;
|
|
31
|
+
stop(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function startBunGeneratedFrontendWatch(
|
|
35
|
+
options: BunGeneratedFrontendWatchOptions,
|
|
36
|
+
): Promise<BunGeneratedFrontendWatchSession> {
|
|
37
|
+
const paths = resolveBunSpaEntryPaths(options.workspaceRoot);
|
|
38
|
+
const pages = await resolveBunSpaPages(paths.workspaceRoot);
|
|
39
|
+
const host = options.host ?? '127.0.0.1';
|
|
40
|
+
const port = options.port ?? 8088;
|
|
41
|
+
|
|
42
|
+
await prepareBunSpaGeneratedEntries({ paths, pages });
|
|
43
|
+
|
|
44
|
+
const servedEntries = await loadServedEntries(paths, pages);
|
|
45
|
+
const servedAddress = createServedAddress(
|
|
46
|
+
host,
|
|
47
|
+
startFrontendServer(host, port, servedEntries, options.apiProxyOrigin),
|
|
48
|
+
);
|
|
49
|
+
const watchers = watchRegenerationTargets(paths, pages, async (nextEntries) => {
|
|
50
|
+
const reloadOptions: Parameters<ReloadableServeServer['reload']>[0] = {
|
|
51
|
+
fetch: createBunFrontendFetchHandler({ apiProxyOrigin: options.apiProxyOrigin }),
|
|
52
|
+
routes: createBunSpaRoutes(nextEntries),
|
|
53
|
+
};
|
|
54
|
+
servedAddress.server.reload(reloadOptions);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return createSession(servedAddress, watchers);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ServedAddress {
|
|
61
|
+
readonly server: ReloadableServeServer;
|
|
62
|
+
readonly address: DevServerAddress;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function startFrontendServer(
|
|
66
|
+
host: string,
|
|
67
|
+
port: number,
|
|
68
|
+
spaEntries: readonly BunSpaRouteEntry[],
|
|
69
|
+
apiProxyOrigin?: string,
|
|
70
|
+
): ReloadableServeServer {
|
|
71
|
+
const serverOptions = {
|
|
72
|
+
hostname: host,
|
|
73
|
+
port,
|
|
74
|
+
routes: createBunSpaRoutes(spaEntries),
|
|
75
|
+
fetch: createBunFrontendFetchHandler({ apiProxyOrigin }),
|
|
76
|
+
};
|
|
77
|
+
return Bun.serve(
|
|
78
|
+
serverOptions as unknown as Parameters<typeof Bun.serve>[0],
|
|
79
|
+
) as ReloadableServeServer;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function createServedAddress(host: string, server: ReloadableServeServer): ServedAddress {
|
|
83
|
+
const originHost = host === '0.0.0.0' ? '127.0.0.1' : host;
|
|
84
|
+
return {
|
|
85
|
+
server,
|
|
86
|
+
address: {
|
|
87
|
+
host: originHost,
|
|
88
|
+
port: server.port,
|
|
89
|
+
origin: `http://${originHost}:${server.port}`,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function watchRegenerationTargets(
|
|
95
|
+
paths: BunSpaEntryPaths,
|
|
96
|
+
pages: readonly BunSpaPageDetails[],
|
|
97
|
+
onEntriesReload: (nextEntries: readonly BunSpaRouteEntry[]) => Promise<void>,
|
|
98
|
+
): Set<FSWatcher> {
|
|
99
|
+
const watchers = new Set<FSWatcher>();
|
|
100
|
+
let pendingRegeneration: Promise<void> | null = null;
|
|
101
|
+
|
|
102
|
+
const regenerationTargets = new Set<string>([paths.appTemplatePath, paths.appCssPath]);
|
|
103
|
+
for (const page of pages) {
|
|
104
|
+
regenerationTargets.add(page.htmlPath);
|
|
105
|
+
if (page.cssPath) {
|
|
106
|
+
regenerationTargets.add(page.cssPath);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const target of regenerationTargets) {
|
|
111
|
+
watchers.add(
|
|
112
|
+
watch(target, () => {
|
|
113
|
+
if (pendingRegeneration) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
pendingRegeneration = regenerateAndReloadSpaEntries(paths, pages, onEntriesReload).finally(
|
|
118
|
+
() => {
|
|
119
|
+
pendingRegeneration = null;
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return watchers;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function regenerateAndReloadSpaEntries(
|
|
130
|
+
paths: BunSpaEntryPaths,
|
|
131
|
+
pages: readonly BunSpaPageDetails[],
|
|
132
|
+
onEntriesReload: (nextEntries: readonly BunSpaRouteEntry[]) => Promise<void>,
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
for (const page of pages) {
|
|
135
|
+
await regenerateBunSpaEntry({ paths, page });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await onEntriesReload(await loadServedEntries(paths, pages));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function loadServedEntries(
|
|
142
|
+
paths: BunSpaEntryPaths,
|
|
143
|
+
pages: readonly BunSpaPageDetails[],
|
|
144
|
+
): Promise<readonly BunSpaRouteEntry[]> {
|
|
145
|
+
return await Promise.all(
|
|
146
|
+
pages.map(async (page, index) => {
|
|
147
|
+
const generatedPaths = resolveBunSpaGeneratedPagePaths(paths, page);
|
|
148
|
+
return {
|
|
149
|
+
routes: resolvePageRoutes(page, index === 0),
|
|
150
|
+
entry: await loadBunSpaEntry(generatedPaths.generatedEntryPath),
|
|
151
|
+
} satisfies BunSpaRouteEntry;
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolvePageRoutes(page: BunSpaPageDetails, isRootPage: boolean): readonly string[] {
|
|
157
|
+
const routes = new Set<string>();
|
|
158
|
+
|
|
159
|
+
if (isRootPage) {
|
|
160
|
+
routes.add('/');
|
|
161
|
+
routes.add('/index.html');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (page.routePath !== '/') {
|
|
165
|
+
routes.add(page.routePath);
|
|
166
|
+
routes.add(`${page.routePath}/`);
|
|
167
|
+
routes.add(`${page.routePath}/index.html`);
|
|
168
|
+
return Array.from(routes);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
routes.add('/home');
|
|
172
|
+
routes.add('/home/');
|
|
173
|
+
routes.add('/home/index.html');
|
|
174
|
+
return Array.from(routes);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function createSession(
|
|
178
|
+
servedAddress: ServedAddress,
|
|
179
|
+
watchers: Set<FSWatcher>,
|
|
180
|
+
): BunGeneratedFrontendWatchSession {
|
|
181
|
+
let stopping = false;
|
|
182
|
+
let exitResolver: ((code: number | null) => void) | undefined;
|
|
183
|
+
const exitPromise = new Promise<number | null>((resolve) => {
|
|
184
|
+
exitResolver = resolve;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
address: servedAddress.address,
|
|
189
|
+
waitForExit() {
|
|
190
|
+
return exitPromise;
|
|
191
|
+
},
|
|
192
|
+
async stop() {
|
|
193
|
+
if (stopping) {
|
|
194
|
+
await exitPromise;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
stopping = true;
|
|
199
|
+
for (const watcher of watchers) {
|
|
200
|
+
watcher.close();
|
|
201
|
+
}
|
|
202
|
+
watchers.clear();
|
|
203
|
+
servedAddress.server.stop(true);
|
|
204
|
+
exitResolver?.(0);
|
|
205
|
+
exitResolver = undefined;
|
|
206
|
+
await exitPromise;
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
interface BunShell {
|
|
2
|
+
(strings: TemplateStringsArray, ...values: unknown[]): Promise<unknown>;
|
|
3
|
+
cwd(cwd: string): BunShell;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
declare const Bun: {
|
|
7
|
+
$: BunShell;
|
|
8
|
+
file(path: string | URL): Blob & {
|
|
9
|
+
text(): Promise<string>;
|
|
10
|
+
};
|
|
11
|
+
write(destination: string | URL, input: string | Blob): Promise<number>;
|
|
12
|
+
serve(options: {
|
|
13
|
+
hostname?: string;
|
|
14
|
+
idleTimeout?: number;
|
|
15
|
+
port: number;
|
|
16
|
+
fetch(request: Request): Response | Promise<Response>;
|
|
17
|
+
}): {
|
|
18
|
+
hostname: string;
|
|
19
|
+
port: number;
|
|
20
|
+
url: URL;
|
|
21
|
+
stop(closeActiveConnections?: boolean): void;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
export interface BunSpaEntryPaths {
|
|
6
|
+
readonly workspaceRoot: string;
|
|
7
|
+
readonly appTemplatePath: string;
|
|
8
|
+
readonly appCssPath: string;
|
|
9
|
+
readonly appScriptPath?: string;
|
|
10
|
+
readonly generatedRoot: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface BunSpaPageDetails {
|
|
14
|
+
readonly name: string;
|
|
15
|
+
readonly routePath: string;
|
|
16
|
+
readonly directory: string;
|
|
17
|
+
readonly htmlPath: string;
|
|
18
|
+
readonly scriptPath?: string;
|
|
19
|
+
readonly cssPath?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BunSpaGeneratedPagePaths {
|
|
23
|
+
readonly generatedPageRoot: string;
|
|
24
|
+
readonly generatedEntryPath: string;
|
|
25
|
+
readonly generatedCssPath: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RegenerateBunSpaEntryOptions {
|
|
29
|
+
readonly paths: BunSpaEntryPaths;
|
|
30
|
+
readonly page: BunSpaPageDetails;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const GENERATED_DIR = path.join('.webstir', 'bun-first-spa');
|
|
34
|
+
const GENERATED_ENTRY = 'index.html';
|
|
35
|
+
const GENERATED_PAGE_CSS = 'page.css';
|
|
36
|
+
const PAGE_SCRIPT_NAMES = ['index.ts', 'index.tsx', 'index.js', 'index.jsx'] as const;
|
|
37
|
+
|
|
38
|
+
export function resolveBunSpaEntryPaths(workspaceRoot: string): BunSpaEntryPaths {
|
|
39
|
+
const resolvedWorkspaceRoot = path.resolve(workspaceRoot);
|
|
40
|
+
const generatedRoot = path.join(resolvedWorkspaceRoot, GENERATED_DIR);
|
|
41
|
+
const appRoot = path.join(resolvedWorkspaceRoot, 'src', 'frontend', 'app');
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
workspaceRoot: resolvedWorkspaceRoot,
|
|
45
|
+
appTemplatePath: path.join(appRoot, 'app.html'),
|
|
46
|
+
appCssPath: path.join(appRoot, 'app.css'),
|
|
47
|
+
appScriptPath: resolveOptionalExistingFileSync(appRoot, [
|
|
48
|
+
'app.ts',
|
|
49
|
+
'app.tsx',
|
|
50
|
+
'app.js',
|
|
51
|
+
'app.jsx',
|
|
52
|
+
]),
|
|
53
|
+
generatedRoot,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function resolveBunSpaPages(
|
|
58
|
+
workspaceRoot: string,
|
|
59
|
+
): Promise<readonly BunSpaPageDetails[]> {
|
|
60
|
+
const pagesRoot = path.join(workspaceRoot, 'src', 'frontend', 'pages');
|
|
61
|
+
const directories = await collectSpaPageDirectories(pagesRoot);
|
|
62
|
+
if (directories.length === 0) {
|
|
63
|
+
throw new Error(`No SPA pages found under ${pagesRoot}.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const pages = await Promise.all(
|
|
67
|
+
directories.map(async (directory) => {
|
|
68
|
+
const pageName = normalizeForwardSlashes(path.relative(pagesRoot, directory));
|
|
69
|
+
const htmlPath = path.join(directory, 'index.html');
|
|
70
|
+
const scriptPath = await resolveOptionalFile(directory, PAGE_SCRIPT_NAMES);
|
|
71
|
+
const cssPath = await resolveOptionalFile(directory, ['index.css']);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
name: pageName,
|
|
75
|
+
routePath: pageName === 'home' ? '/' : `/${pageName}`,
|
|
76
|
+
directory,
|
|
77
|
+
htmlPath,
|
|
78
|
+
scriptPath,
|
|
79
|
+
cssPath,
|
|
80
|
+
} satisfies BunSpaPageDetails;
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
pages.sort((left, right) => comparePageNames(left.name, right.name));
|
|
85
|
+
return pages;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resolveBunSpaGeneratedPagePaths(
|
|
89
|
+
paths: BunSpaEntryPaths,
|
|
90
|
+
page: BunSpaPageDetails,
|
|
91
|
+
): BunSpaGeneratedPagePaths {
|
|
92
|
+
const generatedPageRoot = path.join(paths.generatedRoot, ...page.name.split('/'));
|
|
93
|
+
return {
|
|
94
|
+
generatedPageRoot,
|
|
95
|
+
generatedEntryPath: path.join(generatedPageRoot, GENERATED_ENTRY),
|
|
96
|
+
generatedCssPath: path.join(generatedPageRoot, GENERATED_PAGE_CSS),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function prepareBunSpaGeneratedEntries(options: {
|
|
101
|
+
readonly paths: BunSpaEntryPaths;
|
|
102
|
+
readonly pages: readonly BunSpaPageDetails[];
|
|
103
|
+
}): Promise<void> {
|
|
104
|
+
await mkdir(options.paths.generatedRoot, { recursive: true });
|
|
105
|
+
|
|
106
|
+
for (const page of options.pages) {
|
|
107
|
+
await regenerateBunSpaEntry({
|
|
108
|
+
paths: options.paths,
|
|
109
|
+
page,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function regenerateBunSpaEntry(options: RegenerateBunSpaEntryOptions): Promise<void> {
|
|
115
|
+
const generatedPaths = resolveBunSpaGeneratedPagePaths(options.paths, options.page);
|
|
116
|
+
await mkdir(generatedPaths.generatedPageRoot, { recursive: true });
|
|
117
|
+
const appTemplate = await readFile(options.paths.appTemplatePath, 'utf8');
|
|
118
|
+
const pageHtml = await readFile(options.page.htmlPath, 'utf8');
|
|
119
|
+
const title = extractTitle(pageHtml) ?? extractTitle(appTemplate) ?? 'Webstir SPA';
|
|
120
|
+
const appHead = stripTitle(extractTagContents(appTemplate, 'head') ?? '');
|
|
121
|
+
const pageHead = stripAssetTags(stripTitle(extractTagContents(pageHtml, 'head') ?? ''));
|
|
122
|
+
const mainHtml = extractTagContents(pageHtml, 'main') ?? '';
|
|
123
|
+
const appBodyClass = extractTagAttribute(appTemplate, 'body', 'class');
|
|
124
|
+
const pageBodyClass = extractTagAttribute(pageHtml, 'body', 'class');
|
|
125
|
+
const bodyClass = [appBodyClass, pageBodyClass].filter(Boolean).join(' ').trim();
|
|
126
|
+
const relativeAppScriptPath = options.paths.appScriptPath
|
|
127
|
+
? toRelativeModulePath(generatedPaths.generatedEntryPath, options.paths.appScriptPath)
|
|
128
|
+
: undefined;
|
|
129
|
+
const relativeScriptPath = options.page.scriptPath
|
|
130
|
+
? toRelativeModulePath(generatedPaths.generatedEntryPath, options.page.scriptPath)
|
|
131
|
+
: undefined;
|
|
132
|
+
const relativeStylesheetPath = await writeGeneratedPageCss({
|
|
133
|
+
generatedCssPath: generatedPaths.generatedCssPath,
|
|
134
|
+
generatedEntryPath: generatedPaths.generatedEntryPath,
|
|
135
|
+
pageCssPath: options.page.cssPath,
|
|
136
|
+
appCssPath: options.paths.appCssPath,
|
|
137
|
+
});
|
|
138
|
+
const bodyClassAttribute = bodyClass.length > 0 ? ` class="${escapeAttribute(bodyClass)}"` : '';
|
|
139
|
+
|
|
140
|
+
const output = `<!DOCTYPE html>
|
|
141
|
+
<html lang="en">
|
|
142
|
+
<head>
|
|
143
|
+
<title>${escapeHtml(title)}</title>
|
|
144
|
+
${appHead.trim()}
|
|
145
|
+
${pageHead.trim()}
|
|
146
|
+
<link rel="stylesheet" href="${relativeStylesheetPath}" />
|
|
147
|
+
</head>
|
|
148
|
+
<body${bodyClassAttribute}>
|
|
149
|
+
<main>${mainHtml}</main>
|
|
150
|
+
${relativeAppScriptPath ? `<script type="module" src="${relativeAppScriptPath}"></script>` : ''}
|
|
151
|
+
${relativeScriptPath ? `<script type="module" src="${relativeScriptPath}"></script>` : ''}
|
|
152
|
+
</body>
|
|
153
|
+
</html>
|
|
154
|
+
`;
|
|
155
|
+
|
|
156
|
+
await writeFile(generatedPaths.generatedEntryPath, output, 'utf8');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function resolveOptionalFile(
|
|
160
|
+
directory: string,
|
|
161
|
+
names: readonly string[],
|
|
162
|
+
): Promise<string | undefined> {
|
|
163
|
+
for (const name of names) {
|
|
164
|
+
const candidate = path.join(directory, name);
|
|
165
|
+
try {
|
|
166
|
+
await access(candidate);
|
|
167
|
+
return candidate;
|
|
168
|
+
} catch {
|
|
169
|
+
// Fall through.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface WriteGeneratedPageCssOptions {
|
|
177
|
+
readonly generatedCssPath: string;
|
|
178
|
+
readonly generatedEntryPath: string;
|
|
179
|
+
readonly pageCssPath?: string;
|
|
180
|
+
readonly appCssPath: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function writeGeneratedPageCss(options: WriteGeneratedPageCssOptions): Promise<string> {
|
|
184
|
+
const appCssImport = `@import "${toRelativeModulePath(options.generatedCssPath, options.appCssPath)}";`;
|
|
185
|
+
let css = appCssImport;
|
|
186
|
+
|
|
187
|
+
if (options.pageCssPath) {
|
|
188
|
+
const sourceCss = await readFile(options.pageCssPath, 'utf8');
|
|
189
|
+
const rewritten = sourceCss.replace(/@import\s+["']@app\/app\.css["'];?\s*/gi, '');
|
|
190
|
+
css = `${appCssImport}\n${rewritten.trim()}\n`;
|
|
191
|
+
} else {
|
|
192
|
+
css = `${appCssImport}\n`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await writeFile(options.generatedCssPath, css, 'utf8');
|
|
196
|
+
return toRelativeModulePath(options.generatedEntryPath, options.generatedCssPath);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function extractTagContents(html: string, tagName: string): string | null {
|
|
200
|
+
const pattern = new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i');
|
|
201
|
+
const match = html.match(pattern);
|
|
202
|
+
return match?.[1] ?? null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function extractTagAttribute(
|
|
206
|
+
html: string,
|
|
207
|
+
tagName: string,
|
|
208
|
+
attributeName: string,
|
|
209
|
+
): string | undefined {
|
|
210
|
+
const tagPattern = new RegExp(`<${tagName}\\b([^>]*)>`, 'i');
|
|
211
|
+
const tagMatch = html.match(tagPattern);
|
|
212
|
+
if (!tagMatch?.[1]) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const attributePattern = new RegExp(`${attributeName}="([^"]*)"`, 'i');
|
|
217
|
+
const attributeMatch = tagMatch[1].match(attributePattern);
|
|
218
|
+
return attributeMatch?.[1];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function extractTitle(html: string): string | undefined {
|
|
222
|
+
const title = extractTagContents(html, 'title');
|
|
223
|
+
return title?.trim() || undefined;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function stripTitle(html: string): string {
|
|
227
|
+
return html.replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, '').trim();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function stripAssetTags(html: string): string {
|
|
231
|
+
return html
|
|
232
|
+
.replace(/<link\b[^>]*rel=["']stylesheet["'][^>]*>/gi, '')
|
|
233
|
+
.replace(/<script\b[^>]*src=["'][^"']+["'][^>]*><\/script>/gi, '')
|
|
234
|
+
.trim();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function toRelativeModulePath(fromFile: string, targetFile: string): string {
|
|
238
|
+
const relativePath = path.relative(path.dirname(fromFile), targetFile).split(path.sep).join('/');
|
|
239
|
+
return relativePath.startsWith('.') ? relativePath : `./${relativePath}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function escapeHtml(value: string): string {
|
|
243
|
+
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function escapeAttribute(value: string): string {
|
|
247
|
+
return escapeHtml(value).replaceAll('"', '"');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function resolveOptionalExistingFileSync(
|
|
251
|
+
directory: string,
|
|
252
|
+
names: readonly string[],
|
|
253
|
+
): string | undefined {
|
|
254
|
+
for (const name of names) {
|
|
255
|
+
const candidate = path.join(directory, name);
|
|
256
|
+
if (existsSync(candidate)) {
|
|
257
|
+
return candidate;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function collectSpaPageDirectories(root: string): Promise<string[]> {
|
|
265
|
+
try {
|
|
266
|
+
await access(root);
|
|
267
|
+
} catch {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const directories: string[] = [];
|
|
272
|
+
const stack = [path.resolve(root)];
|
|
273
|
+
|
|
274
|
+
while (stack.length > 0) {
|
|
275
|
+
const current = stack.pop();
|
|
276
|
+
if (!current) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
280
|
+
const hasIndexHtml = entries.some((entry) => entry.isFile() && entry.name === 'index.html');
|
|
281
|
+
if (hasIndexHtml) {
|
|
282
|
+
directories.push(current);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const entry of entries) {
|
|
287
|
+
if (entry.isDirectory()) {
|
|
288
|
+
stack.push(path.join(current, entry.name));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return directories;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function comparePageNames(left: string, right: string): number {
|
|
297
|
+
if (left === 'home') {
|
|
298
|
+
return -1;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (right === 'home') {
|
|
302
|
+
return 1;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return left.localeCompare(right);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function normalizeForwardSlashes(value: string): string {
|
|
309
|
+
return value.split(path.sep).join('/');
|
|
310
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
export type ReloadableServeServer = ReturnType<typeof Bun.serve> & {
|
|
5
|
+
reload(options: unknown): unknown;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export interface BunFrontendFetchHandlerOptions {
|
|
9
|
+
readonly apiProxyOrigin?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface BunSpaRouteEntry {
|
|
13
|
+
readonly routes: readonly string[];
|
|
14
|
+
readonly entry: BodyInit;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function loadBunSpaEntry(generatedEntryPath: string): Promise<BodyInit> {
|
|
18
|
+
const routeModule = (await import(
|
|
19
|
+
`${pathToFileURL(generatedEntryPath).href}?t=${Date.now()}`
|
|
20
|
+
)) as { default: unknown };
|
|
21
|
+
return routeModule.default as BodyInit;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createBunSpaRoutes(entries: readonly BunSpaRouteEntry[]) {
|
|
25
|
+
const routes: Record<string, BodyInit | false> = {
|
|
26
|
+
'/api': false,
|
|
27
|
+
'/api/*': false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
for (const route of entry.routes) {
|
|
32
|
+
routes[route] = entry.entry;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return routes;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createBunFrontendFetchHandler(options: BunFrontendFetchHandlerOptions = {}) {
|
|
40
|
+
return async (request: Request) => {
|
|
41
|
+
const requestUrl = new URL(request.url);
|
|
42
|
+
const apiProxyPath = getApiProxyPath(requestUrl.pathname);
|
|
43
|
+
if (apiProxyPath !== null) {
|
|
44
|
+
if (!options.apiProxyOrigin) {
|
|
45
|
+
return new Response('Not found.', { status: 404 });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return await proxyApiRequest(request, requestUrl, apiProxyPath, options.apiProxyOrigin);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
52
|
+
return new Response('Method not allowed.', { status: 405 });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return new Response('Not found.', { status: 404 });
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getApiProxyPath(pathname: string): string | null {
|
|
60
|
+
if (pathname === '/api') {
|
|
61
|
+
return '/';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (pathname.startsWith('/api/')) {
|
|
65
|
+
const normalizedPath = path.posix.normalize(pathname.slice('/api'.length));
|
|
66
|
+
return normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function proxyApiRequest(
|
|
73
|
+
request: Request,
|
|
74
|
+
requestUrl: URL,
|
|
75
|
+
apiProxyPath: string,
|
|
76
|
+
apiProxyOrigin: string,
|
|
77
|
+
): Promise<Response> {
|
|
78
|
+
const targetUrl = new URL(apiProxyPath + requestUrl.search, apiProxyOrigin);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const proxyResponse = await fetch(targetUrl, createProxyRequestInit(request, targetUrl));
|
|
82
|
+
const headers = rewriteProxyResponseHeaders(proxyResponse.headers, targetUrl);
|
|
83
|
+
|
|
84
|
+
return new Response(request.method !== 'HEAD' ? proxyResponse.body : null, {
|
|
85
|
+
status: proxyResponse.status || 502,
|
|
86
|
+
headers,
|
|
87
|
+
});
|
|
88
|
+
} catch {
|
|
89
|
+
return new Response('Backend proxy failed.', { status: 502 });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function rewriteProxyResponseHeaders(headers: Headers, targetUrl: URL): Headers {
|
|
94
|
+
const nextHeaders = new Headers(headers);
|
|
95
|
+
const location = headers.get('location');
|
|
96
|
+
if (location) {
|
|
97
|
+
nextHeaders.set('location', rewriteProxyLocation(location, targetUrl));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return nextHeaders;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createProxyRequestInit(
|
|
104
|
+
request: Request,
|
|
105
|
+
targetUrl: URL,
|
|
106
|
+
): RequestInit & { duplex?: 'half' } {
|
|
107
|
+
const headers = new Headers(request.headers);
|
|
108
|
+
headers.set('host', targetUrl.host);
|
|
109
|
+
headers.set('connection', 'close');
|
|
110
|
+
|
|
111
|
+
const requestInit: RequestInit & { duplex?: 'half' } = {
|
|
112
|
+
method: request.method,
|
|
113
|
+
headers,
|
|
114
|
+
redirect: 'manual',
|
|
115
|
+
signal: request.signal,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (methodAllowsBody(request.method)) {
|
|
119
|
+
requestInit.body = request.body;
|
|
120
|
+
if (request.body) {
|
|
121
|
+
requestInit.duplex = 'half';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return requestInit;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function rewriteProxyLocation(value: string, targetUrl: URL): string {
|
|
129
|
+
const trimmed = value.trim();
|
|
130
|
+
if (!trimmed) {
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (trimmed.startsWith('/')) {
|
|
135
|
+
return prefixApiMount(trimmed);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const resolved = new URL(trimmed, targetUrl.origin);
|
|
140
|
+
if (resolved.origin !== targetUrl.origin) {
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
return prefixApiMount(`${resolved.pathname}${resolved.search}${resolved.hash}`);
|
|
144
|
+
} catch {
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function prefixApiMount(pathname: string): string {
|
|
150
|
+
if (pathname === '/api' || pathname.startsWith('/api/')) {
|
|
151
|
+
return pathname;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return pathname === '/' ? '/api' : `/api${pathname}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function methodAllowsBody(method: string): boolean {
|
|
158
|
+
return method !== 'GET' && method !== 'HEAD';
|
|
159
|
+
}
|