Skip to content

Commit 10d1c06

Browse files
committed
feat: support builtin dev server
1 parent c97868a commit 10d1c06

File tree

14 files changed

+878
-4
lines changed

14 files changed

+878
-4
lines changed

.changeset/green-crabs-add.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ice/pkg': minor
3+
---
4+
5+
feat: support builtin dev server

packages/pkg/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,19 @@
5454
"chokidar": "^3.5.3",
5555
"cli-spinners": "^2.9.2",
5656
"consola": "^3.4.2",
57+
"cors": "^2.8.5",
5758
"debug": "^4.3.3",
5859
"es-module-lexer": "^1.3.1",
5960
"es-toolkit": "^1.32.0",
61+
"express": "^5.2.1",
6062
"figures": "^6.1.0",
6163
"fs-extra": "^10.0.0",
6264
"get-tsconfig": "^4.13.0",
6365
"globby": "^11.0.4",
6466
"gzip-size": "^7.0.0",
67+
"http-proxy-middleware": "^3.0.5",
6568
"magic-string": "^0.25.7",
69+
"open": "^11.0.0",
6670
"oxc-transform": "~0.89.0",
6771
"picocolors": "^1.0.0",
6872
"postcss": "^8.4.31",
@@ -79,7 +83,9 @@
7983
},
8084
"devDependencies": {
8185
"@types/babel__core": "^7.1.20",
86+
"@types/cors": "^2.8.19",
8287
"@types/debug": "^4.1.12",
88+
"@types/express": "^5.0.6",
8389
"@types/fs-extra": "^9.0.13",
8490
"@types/semver": "^7.7.1",
8591
"cssnano": "^5.1.15",

packages/pkg/src/cli.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ const cli = cac('ice-pkg');
4545
.option('--rootDir <rootDir>', 'specify root directory', {
4646
default: process.cwd(),
4747
})
48+
.option('--server', 'Override server config', {})
49+
.option('--port <port>', 'Override default server port', {})
50+
.option('--host <host>', 'Override default server host', {})
4851
.action(async (options) => {
4952
delete options['--'];
5053
const { rootDir, ...commandArgs } = options;

packages/pkg/src/commands/start.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import type { OutputResult, Context, WatchChangedFile, BuildTask } from '../type
44
import { RunnerLinerTerminalReporter } from '../helpers/runnerReporter.js';
55
import { getTaskRunners } from '../helpers/getTaskRunners.js';
66
import { RunnerScheduler } from '../helpers/runnerScheduler.js';
7+
import { createServer } from '../server/createServer.js';
78

89
export default async function start(context: Context) {
9-
const { applyHook, commandArgs } = context;
10+
const { applyHook, commandArgs, userConfig } = context;
1011

1112
const buildTasks = context.getTaskConfig() as BuildTask[];
1213
const taskConfigs = buildTasks.map(({ config }) => config);
@@ -26,6 +27,14 @@ export default async function start(context: Context) {
2627
});
2728

2829
const watcher = createWatcher(taskConfigs);
30+
const serverConfig = commandArgs.server !== undefined ? commandArgs.server : userConfig.server;
31+
const devServer = serverConfig
32+
? createServer({
33+
...(serverConfig === true ? {} : serverConfig),
34+
...(commandArgs.port ? { port: commandArgs.port } : {}),
35+
...(commandArgs.host ? { host: commandArgs.host } : {}),
36+
})
37+
: null;
2938
const batchHandler = createBatchChangeHandler(runChangedCompile);
3039
batchHandler.beginBlock();
3140

@@ -43,6 +52,8 @@ export default async function start(context: Context) {
4352

4453
await applyHook('after.start.compile', outputResults);
4554

55+
await devServer?.listen();
56+
devServer?.printUrls();
4657
batchHandler.endBlock();
4758

4859
async function runChangedCompile(changedFiles: WatchChangedFile[]) {

packages/pkg/src/config/cliOptions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ function getCliOptions() {
1010
return mergeValueToTaskConfig(config, 'analyzer', analyzer);
1111
},
1212
},
13+
{
14+
name: 'server',
15+
commands: ['start'],
16+
},
1317
];
1418
return cliOptions;
1519
}

packages/pkg/src/config/schema.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ export const bundleSchema = z.object({
2121
.union([
2222
z.boolean(),
2323
z.object({
24-
js: z.union([z.boolean(), z.function()]),
25-
css: z.union([z.boolean(), z.function()]),
24+
js: z.union([z.boolean(), z.function()]).optional(),
25+
css: z.union([z.boolean(), z.function()]).optional(),
2626
}),
2727
])
2828
.optional(),
@@ -32,6 +32,25 @@ export const bundleSchema = z.object({
3232
codeSplitting: z.boolean().optional(),
3333
});
3434

35+
export const serverPublicDirOptionSchema = z.object({
36+
name: z.string().optional(),
37+
});
38+
39+
export const serverPublicDirOptionWithStringSchema = z.union([z.string(), serverPublicDirOptionSchema]);
40+
41+
export const serverSchema = z.object({
42+
publicDir: z
43+
.union([serverPublicDirOptionWithStringSchema, z.array(serverPublicDirOptionWithStringSchema)])
44+
.optional(),
45+
port: z.number().optional(),
46+
https: z.any().optional(),
47+
host: z.string().optional(),
48+
headers: z.record(z.string(), z.union([z.string(), z.string().array()])).optional(),
49+
cors: z.union([z.boolean(), z.any()]).optional(),
50+
proxy: z.union([z.record(z.string(), z.union([z.string(), z.any()])), z.any().array()]).optional(),
51+
autoServeBundle: z.boolean().optional(),
52+
});
53+
3554
export const userConfigSchema = z.object({
3655
entry: z.union([z.string(), z.string().array(), z.record(z.string(), z.string())]).optional(),
3756
alias: z.record(z.string(), z.string()).optional(),
@@ -52,6 +71,7 @@ export const userConfigSchema = z.object({
5271
generator: z.enum(['tsc', 'oxc']).optional(),
5372
}),
5473
]),
74+
server: z.union([z.boolean(), serverSchema]).optional(),
5575
});
5676

5777
export type UserConfigSchemaType = z.infer<typeof userConfigSchema>;
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { ServerUserConfig } from '../types.js';
2+
import type Express from 'express';
3+
import express from 'express';
4+
import http from 'node:http';
5+
import https from 'node:https';
6+
import http2 from 'node:http2';
7+
import path from 'node:path';
8+
import fs from 'node:fs';
9+
import { createProxyMiddleware } from 'http-proxy-middleware';
10+
import { consola } from 'consola';
11+
import pc from 'picocolors';
12+
import cors from 'cors';
13+
import { getAddressUrls } from './utils.js';
14+
15+
export const DEFAULT_PORT = 5138;
16+
export const DEFAULT_HOST = '0.0.0.0';
17+
18+
export interface PkgServer {
19+
app: Express.Application;
20+
httpServer: import('node:http').Server | import('node:https').Server | import('node:http2').Http2SecureServer | null;
21+
listen: () => Promise<{
22+
port: number;
23+
urls: string[];
24+
server: {
25+
close: () => Promise<void>;
26+
};
27+
}>;
28+
port: number;
29+
close: () => Promise<void>;
30+
printUrls: () => void;
31+
}
32+
33+
export function createServer(serverConfig: ServerUserConfig): PkgServer {
34+
const {
35+
port: configPort = DEFAULT_PORT,
36+
host = DEFAULT_HOST,
37+
publicDir: publicDirConfig,
38+
headers,
39+
cors: corsConfig,
40+
proxy,
41+
autoServeBundle = true,
42+
https: httpsConfig,
43+
} = serverConfig;
44+
const app = express();
45+
46+
if (headers) {
47+
app.use((req, res, next) => {
48+
Object.entries(headers).forEach(([key, value]) => {
49+
if (Array.isArray(value)) {
50+
value.forEach((v) => res.setHeader(key, v));
51+
} else {
52+
res.setHeader(key, value);
53+
}
54+
});
55+
next();
56+
});
57+
}
58+
59+
if (proxy) {
60+
if (Array.isArray(proxy)) {
61+
proxy.forEach((p) => app.use(createProxyMiddleware(p)));
62+
} else {
63+
Object.entries(proxy).forEach(([context, options]) => {
64+
if (typeof options === 'string') {
65+
app.use(context, createProxyMiddleware({ target: options, changeOrigin: true }));
66+
} else {
67+
app.use(context, createProxyMiddleware(options));
68+
}
69+
});
70+
}
71+
}
72+
73+
if (corsConfig !== false) {
74+
const options = corsConfig === true ? undefined : corsConfig;
75+
app.use(cors(options));
76+
}
77+
78+
if (autoServeBundle) {
79+
app.use(express.static(path.resolve(process.cwd(), 'dist')));
80+
}
81+
82+
if (publicDirConfig) {
83+
const dirs = Array.isArray(publicDirConfig) ? publicDirConfig : [publicDirConfig];
84+
dirs.forEach((dir) => {
85+
let publicDir: string | undefined;
86+
if (typeof dir === 'string') {
87+
publicDir = dir;
88+
} else if (typeof dir === 'object') {
89+
publicDir = dir.name;
90+
}
91+
92+
if (publicDir) {
93+
const staticPath = path.resolve(process.cwd(), publicDir);
94+
if (fs.existsSync(staticPath)) {
95+
app.use(express.static(staticPath));
96+
}
97+
}
98+
});
99+
}
100+
101+
let httpServer: http.Server | https.Server | http2.Http2SecureServer;
102+
103+
if (httpsConfig) {
104+
if (proxy) {
105+
// http-proxy-middleware is not compatible with HTTP/2
106+
httpServer = https.createServer(httpsConfig as https.ServerOptions, app);
107+
} else {
108+
httpServer = http2.createSecureServer(
109+
{
110+
allowHTTP1: true,
111+
maxSessionMemory: 1024,
112+
...httpsConfig,
113+
},
114+
// @ts-expect-error req type mismatch
115+
app,
116+
);
117+
}
118+
} else {
119+
httpServer = http.createServer(app);
120+
}
121+
122+
let resolvedPort = configPort;
123+
124+
const devServerAPI: PkgServer = {
125+
app: app,
126+
httpServer,
127+
get port() {
128+
return resolvedPort;
129+
},
130+
listen: async () => {
131+
const findPort = async (startPort: number): Promise<number> => {
132+
return new Promise((resolve, reject) => {
133+
const s = http.createServer();
134+
s.on('error', (err: any) => {
135+
if (err.code === 'EADDRINUSE') {
136+
s.close();
137+
resolve(findPort(startPort + 1));
138+
} else {
139+
reject(err);
140+
}
141+
});
142+
s.listen(startPort, host, () => {
143+
s.close(() => resolve(startPort));
144+
});
145+
});
146+
};
147+
148+
resolvedPort = await findPort(resolvedPort);
149+
150+
return new Promise((resolve) => {
151+
httpServer.listen(resolvedPort, host, () => {
152+
const hostname = host === '0.0.0.0' ? 'localhost' : host;
153+
const protocol = httpsConfig ? 'https' : 'http';
154+
const urls = [`${protocol}://${hostname}:${resolvedPort}`];
155+
resolve({
156+
port: resolvedPort,
157+
urls,
158+
server: {
159+
close: devServerAPI.close,
160+
},
161+
});
162+
});
163+
});
164+
},
165+
close: async () => {
166+
if (httpServer.listening) {
167+
return new Promise<void>((resolve, reject) => {
168+
httpServer.close((err) => {
169+
if (err) reject(err);
170+
else resolve();
171+
});
172+
});
173+
}
174+
},
175+
printUrls: () => {
176+
const protocol = httpsConfig ? 'https' : 'http';
177+
const urls = getAddressUrls(protocol, resolvedPort, host);
178+
// eslint-disable-next-line no-console
179+
console.log();
180+
urls.forEach(({ label, url }) => {
181+
consola.log(`${label}${pc.cyan(url)}`);
182+
});
183+
// eslint-disable-next-line no-console
184+
console.log();
185+
},
186+
};
187+
188+
return devServerAPI;
189+
}

packages/pkg/src/server/utils.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import os from 'node:os';
2+
import pc from 'picocolors';
3+
4+
export const getIpv4Interfaces = () => {
5+
const interfaces = os.networkInterfaces();
6+
const ipv4Interfaces: os.NetworkInterfaceInfo[] = [];
7+
8+
Object.values(interfaces).forEach((key) => {
9+
key?.forEach((detail) => {
10+
// 'IPv4' is in Node <= 17, from 18 it's a number 4 or 6
11+
if (detail.family === 'IPv4' || (detail.family as any) === 4) {
12+
ipv4Interfaces.push(detail);
13+
}
14+
});
15+
});
16+
return ipv4Interfaces;
17+
};
18+
19+
export const getAddressUrls = (protocol: string, port: number, host: string) => {
20+
const LOCAL_LABEL = ` ${pc.green('➜')} ${pc.bold('Local')}: `;
21+
const NETWORK_LABEL = ` ${pc.green('➜')} ${pc.bold('Network')}: `;
22+
23+
if (host && host !== '0.0.0.0') {
24+
return [{ label: LOCAL_LABEL, url: `${protocol}://${host}:${port}` }];
25+
}
26+
27+
const urls: Array<{ label: string; url: string }> = [];
28+
const interfaces = getIpv4Interfaces();
29+
let hasLocal = false;
30+
interfaces.forEach((detail) => {
31+
if (detail.internal || detail.address === '127.0.0.1' || detail.address === '::1') {
32+
if (!hasLocal) {
33+
urls.push({ label: LOCAL_LABEL, url: `${protocol}://localhost:${port}` });
34+
hasLocal = true;
35+
}
36+
} else {
37+
urls.push({ label: NETWORK_LABEL, url: `${protocol}://${detail.address}:${port}` });
38+
}
39+
});
40+
return urls;
41+
};

0 commit comments

Comments
 (0)