Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/green-crabs-add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ice/pkg': minor
---

feat: support builtin dev server
5 changes: 5 additions & 0 deletions packages/pkg/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,17 @@
"chokidar": "^3.5.3",
"cli-spinners": "^2.9.2",
"consola": "^3.4.2",
"cors": "^2.8.5",
"debug": "^4.3.3",
"es-module-lexer": "^1.3.1",
"es-toolkit": "^1.32.0",
"express": "^5.2.1",
"figures": "^6.1.0",
"fs-extra": "^10.0.0",
"get-tsconfig": "^4.13.0",
"globby": "^11.0.4",
"gzip-size": "^7.0.0",
"http-proxy-middleware": "^3.0.5",
"magic-string": "^0.25.7",
"oxc-transform": "~0.89.0",
"picocolors": "^1.0.0",
Expand All @@ -79,7 +82,9 @@
},
"devDependencies": {
"@types/babel__core": "^7.1.20",
"@types/cors": "^2.8.19",
"@types/debug": "^4.1.12",
"@types/express": "^5.0.6",
"@types/fs-extra": "^9.0.13",
"@types/semver": "^7.7.1",
"cssnano": "^5.1.15",
Expand Down
3 changes: 3 additions & 0 deletions packages/pkg/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ const cli = cac('ice-pkg');
.option('--rootDir <rootDir>', 'specify root directory', {
default: process.cwd(),
})
.option('--server', 'Override server config', {})
.option('--port <port>', 'Override default server port', {})
.option('--host <host>', 'Override default server host', {})
.action(async (options) => {
delete options['--'];
const { rootDir, ...commandArgs } = options;
Expand Down
13 changes: 12 additions & 1 deletion packages/pkg/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import type { OutputResult, Context, WatchChangedFile, BuildTask } from '../type
import { RunnerLinerTerminalReporter } from '../helpers/runnerReporter.js';
import { getTaskRunners } from '../helpers/getTaskRunners.js';
import { RunnerScheduler } from '../helpers/runnerScheduler.js';
import { createServer } from '../server/createServer.js';

export default async function start(context: Context) {
const { applyHook, commandArgs } = context;
const { applyHook, commandArgs, userConfig } = context;

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

const watcher = createWatcher(taskConfigs);
const serverConfig = commandArgs.server !== undefined ? commandArgs.server : userConfig.server;
const devServer = serverConfig
? createServer({
...(serverConfig === true ? {} : serverConfig),
...(commandArgs.port ? { port: commandArgs.port } : {}),
...(commandArgs.host ? { host: commandArgs.host } : {}),
})
: null;
const batchHandler = createBatchChangeHandler(runChangedCompile);
batchHandler.beginBlock();

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

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

await devServer?.listen();
devServer?.printUrls();
batchHandler.endBlock();

async function runChangedCompile(changedFiles: WatchChangedFile[]) {
Expand Down
4 changes: 4 additions & 0 deletions packages/pkg/src/config/cliOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ function getCliOptions() {
return mergeValueToTaskConfig(config, 'analyzer', analyzer);
},
},
{
name: 'server',
commands: ['start'],
},
];
return cliOptions;
}
Expand Down
24 changes: 22 additions & 2 deletions packages/pkg/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ export const bundleSchema = z.object({
.union([
z.boolean(),
z.object({
js: z.union([z.boolean(), z.function()]),
css: z.union([z.boolean(), z.function()]),
js: z.union([z.boolean(), z.function()]).optional(),
css: z.union([z.boolean(), z.function()]).optional(),
}),
])
.optional(),
Expand All @@ -32,6 +32,25 @@ export const bundleSchema = z.object({
codeSplitting: z.boolean().optional(),
});

export const serverPublicDirOptionSchema = z.object({
name: z.string().optional(),
});

export const serverPublicDirOptionWithStringSchema = z.union([z.string(), serverPublicDirOptionSchema]);

export const serverSchema = z.object({
publicDir: z
.union([serverPublicDirOptionWithStringSchema, z.array(serverPublicDirOptionWithStringSchema)])
.optional(),
port: z.number().optional(),
https: z.any().optional(),
host: z.string().optional(),
headers: z.record(z.string(), z.union([z.string(), z.string().array()])).optional(),
cors: z.union([z.boolean(), z.any()]).optional(),
proxy: z.union([z.record(z.string(), z.union([z.string(), z.any()])), z.any().array()]).optional(),
autoServeBundle: z.boolean().optional(),
});

export const userConfigSchema = z.object({
entry: z.union([z.string(), z.string().array(), z.record(z.string(), z.string())]).optional(),
alias: z.record(z.string(), z.string()).optional(),
Expand All @@ -52,6 +71,7 @@ export const userConfigSchema = z.object({
generator: z.enum(['tsc', 'oxc']).optional(),
}),
]),
server: z.union([z.boolean(), serverSchema]).optional(),
});

export type UserConfigSchemaType = z.infer<typeof userConfigSchema>;
189 changes: 189 additions & 0 deletions packages/pkg/src/server/createServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import { ServerUserConfig } from '../types.js';
import type Express from 'express';
import express from 'express';
import http from 'node:http';
import https from 'node:https';
import http2 from 'node:http2';
import path from 'node:path';
import fs from 'node:fs';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { consola } from 'consola';
import pc from 'picocolors';
import cors from 'cors';
import { getAddressUrls } from './utils.js';

export const DEFAULT_PORT = 5138;
export const DEFAULT_HOST = '0.0.0.0';

export interface PkgServer {
app: Express.Application;
httpServer: import('node:http').Server | import('node:https').Server | import('node:http2').Http2SecureServer | null;
listen: () => Promise<{
port: number;
urls: string[];
server: {
close: () => Promise<void>;
};
}>;
port: number;
close: () => Promise<void>;
printUrls: () => void;
}

export function createServer(serverConfig: ServerUserConfig): PkgServer {
const {
port: configPort = DEFAULT_PORT,
host = DEFAULT_HOST,
publicDir: publicDirConfig,
headers,
cors: corsConfig,
proxy,
autoServeBundle = true,
https: httpsConfig,
} = serverConfig;
const app = express();

if (headers) {
app.use((req, res, next) => {
Object.entries(headers).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((v) => res.setHeader(key, v));
} else {
res.setHeader(key, value);
}
});
next();
});
}

if (proxy) {
if (Array.isArray(proxy)) {
proxy.forEach((p) => app.use(createProxyMiddleware(p)));
} else {
Object.entries(proxy).forEach(([context, options]) => {
if (typeof options === 'string') {
app.use(context, createProxyMiddleware({ target: options, changeOrigin: true }));
} else {
app.use(context, createProxyMiddleware(options));
}
});
}
}

if (corsConfig !== false) {
const options = corsConfig === true ? undefined : corsConfig;
app.use(cors(options));
}

if (autoServeBundle) {
app.use(express.static(path.resolve(process.cwd(), 'dist')));
}

if (publicDirConfig) {
const dirs = Array.isArray(publicDirConfig) ? publicDirConfig : [publicDirConfig];
dirs.forEach((dir) => {
let publicDir: string | undefined;
if (typeof dir === 'string') {
publicDir = dir;
} else if (typeof dir === 'object') {
publicDir = dir.name;
}

if (publicDir) {
const staticPath = path.resolve(process.cwd(), publicDir);
if (fs.existsSync(staticPath)) {
app.use(express.static(staticPath));
}
}
});
}

let httpServer: http.Server | https.Server | http2.Http2SecureServer;

if (httpsConfig) {
if (proxy) {
// http-proxy-middleware is not compatible with HTTP/2
httpServer = https.createServer(httpsConfig as https.ServerOptions, app);
} else {
httpServer = http2.createSecureServer(
{
allowHTTP1: true,
maxSessionMemory: 1024,
...httpsConfig,
},
// @ts-expect-error req type mismatch
app,
);
}
} else {
httpServer = http.createServer(app);
}

let resolvedPort = configPort;

const devServerAPI: PkgServer = {
app: app,
httpServer,
get port() {
return resolvedPort;
},
listen: async () => {
const findPort = async (startPort: number): Promise<number> => {
return new Promise((resolve, reject) => {
const s = http.createServer();
s.on('error', (err: any) => {
if (err.code === 'EADDRINUSE') {
s.close();
resolve(findPort(startPort + 1));
} else {
reject(err);
}
});
s.listen(startPort, host, () => {
s.close(() => resolve(startPort));
});
});
};

resolvedPort = await findPort(resolvedPort);

return new Promise((resolve) => {
httpServer.listen(resolvedPort, host, () => {
const hostname = host === '0.0.0.0' ? 'localhost' : host;
const protocol = httpsConfig ? 'https' : 'http';
const urls = [`${protocol}://${hostname}:${resolvedPort}`];
resolve({
port: resolvedPort,
urls,
server: {
close: devServerAPI.close,
},
});
});
});
},
close: async () => {
if (httpServer.listening) {
return new Promise<void>((resolve, reject) => {
httpServer.close((err) => {
if (err) reject(err);
else resolve();
});
});
}
},
printUrls: () => {
const protocol = httpsConfig ? 'https' : 'http';
const urls = getAddressUrls(protocol, resolvedPort, host);
// eslint-disable-next-line no-console
console.log();
urls.forEach(({ label, url }) => {
consola.log(`${label}${pc.cyan(url)}`);
});
// eslint-disable-next-line no-console
console.log();
},
};

return devServerAPI;
}
41 changes: 41 additions & 0 deletions packages/pkg/src/server/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import os from 'node:os';
import pc from 'picocolors';

export const getIpv4Interfaces = () => {
const interfaces = os.networkInterfaces();
const ipv4Interfaces: os.NetworkInterfaceInfo[] = [];

Object.values(interfaces).forEach((key) => {
key?.forEach((detail) => {
// 'IPv4' is in Node <= 17, from 18 it's a number 4 or 6
if (detail.family === 'IPv4' || (detail.family as any) === 4) {
ipv4Interfaces.push(detail);
}
});
});
return ipv4Interfaces;
};

export const getAddressUrls = (protocol: string, port: number, host: string) => {
const LOCAL_LABEL = ` ${pc.green('➜')} ${pc.bold('Local')}: `;
const NETWORK_LABEL = ` ${pc.green('➜')} ${pc.bold('Network')}: `;

if (host && host !== '0.0.0.0') {
return [{ label: LOCAL_LABEL, url: `${protocol}://${host}:${port}` }];
}

const urls: Array<{ label: string; url: string }> = [];
const interfaces = getIpv4Interfaces();
let hasLocal = false;
interfaces.forEach((detail) => {
if (detail.internal || detail.address === '127.0.0.1' || detail.address === '::1') {
if (!hasLocal) {
urls.push({ label: LOCAL_LABEL, url: `${protocol}://localhost:${port}` });
hasLocal = true;
}
} else {
urls.push({ label: NETWORK_LABEL, url: `${protocol}://${detail.address}:${port}` });
}
});
return urls;
};
Loading
Loading