@ywal123456/jskim 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.
@@ -0,0 +1,272 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const { EventEmitter } = require('node:events');
5
+ const chokidar = require('chokidar');
6
+ const { resolveWatchPaths } = require('./resolve-watch-paths');
7
+ const { runBuild } = require('./build-project');
8
+
9
+ /**
10
+ * プロジェクトのファイル監視と全体再ビルドを担当する共通コアです。
11
+ * watch.js / dev.js の両方から再利用します。
12
+ *
13
+ * イベント:
14
+ * - change({ events })
15
+ * - build:start({ initial, events })
16
+ * - build:success({ initial, result, events })
17
+ * - build:failure({ initial, error, events })
18
+ * - ready({ displayPaths, debounceMs })
19
+ * - error(err)
20
+ *
21
+ * @param {object} project 解決済みプロジェクト
22
+ * @param {object} [options]
23
+ * @param {boolean} [options.runInitialBuild=true]
24
+ * @param {boolean} [options.logChanges=true]
25
+ * @returns {EventEmitter & { start: Function, close: Function, displayPaths: string[] }}
26
+ */
27
+ function createProjectWatcher(project, options = {}) {
28
+ const runInitialBuild = options.runInitialBuild !== false;
29
+ const logChanges = options.logChanges !== false;
30
+ const workspaceRoot = project.workspaceRoot;
31
+ const debounceMs = project.watch.debounce;
32
+
33
+ const { absolutePaths, displayPaths } = resolveWatchPaths(project);
34
+ const emitter = new EventEmitter();
35
+
36
+ let chokidarWatcher = null;
37
+ let debounceTimer = null;
38
+ let building = false;
39
+ let rebuildPending = false;
40
+ let stopping = false;
41
+ let started = false;
42
+ let pendingEvents = [];
43
+ let buildPromise = null;
44
+
45
+ async function start(startOptions = {}) {
46
+ if (started && chokidarWatcher) {
47
+ return;
48
+ }
49
+
50
+ const watchFiles = startOptions.watchFiles !== false;
51
+
52
+ if (!started) {
53
+ started = true;
54
+ if (runInitialBuild) {
55
+ await executeBuild({ initial: true, events: [] });
56
+ }
57
+ }
58
+
59
+ if (watchFiles) {
60
+ beginWatching();
61
+ }
62
+ }
63
+
64
+ function beginWatching() {
65
+ if (chokidarWatcher || stopping) {
66
+ return;
67
+ }
68
+
69
+ emitter.emit('ready', { displayPaths, debounceMs });
70
+
71
+ chokidarWatcher = chokidar.watch(absolutePaths, {
72
+ ignoreInitial: true,
73
+ persistent: true,
74
+ awaitWriteFinish: {
75
+ stabilityThreshold: 50,
76
+ pollInterval: 25,
77
+ },
78
+ ignored: [
79
+ /(^|[/\\])node_modules([/\\]|$)/,
80
+ /(^|[/\\])dist([/\\]|$)/,
81
+ ],
82
+ });
83
+
84
+ chokidarWatcher.on('all', (eventName, filePath) => {
85
+ if (
86
+ eventName === 'add' ||
87
+ eventName === 'addDir' ||
88
+ eventName === 'change' ||
89
+ eventName === 'unlink' ||
90
+ eventName === 'unlinkDir'
91
+ ) {
92
+ queueChange(eventName, filePath);
93
+ }
94
+ });
95
+
96
+ chokidarWatcher.on('error', (err) => {
97
+ const message = err && err.message ? err.message : String(err);
98
+ console.error(`[JSKim] ウォッチャーエラー: ${message}`);
99
+ emitter.emit('error', err);
100
+ });
101
+ }
102
+
103
+ async function startWatching() {
104
+ if (!started && runInitialBuild) {
105
+ started = true;
106
+ await executeBuild({ initial: true, events: [] });
107
+ }
108
+ started = true;
109
+ beginWatching();
110
+ }
111
+
112
+ function queueChange(eventName, filePath) {
113
+ if (stopping) {
114
+ return;
115
+ }
116
+
117
+ pendingEvents.push({
118
+ event: eventName,
119
+ file: toDisplayPath(filePath, workspaceRoot),
120
+ });
121
+
122
+ if (debounceTimer) {
123
+ clearTimeout(debounceTimer);
124
+ }
125
+
126
+ debounceTimer = setTimeout(() => {
127
+ debounceTimer = null;
128
+ triggerBuild();
129
+ }, debounceMs);
130
+ }
131
+
132
+ function triggerBuild() {
133
+ if (stopping) {
134
+ return;
135
+ }
136
+
137
+ if (building) {
138
+ rebuildPending = true;
139
+ return;
140
+ }
141
+
142
+ const events = pendingEvents;
143
+ pendingEvents = [];
144
+ building = true;
145
+
146
+ buildPromise = (async () => {
147
+ try {
148
+ await executeBuild({ initial: false, events });
149
+ } finally {
150
+ building = false;
151
+ buildPromise = null;
152
+
153
+ if (!stopping && rebuildPending) {
154
+ rebuildPending = false;
155
+ triggerBuild();
156
+ }
157
+ }
158
+ })();
159
+ }
160
+
161
+ async function executeBuild({ initial, events }) {
162
+ if (!initial) {
163
+ if (logChanges) {
164
+ logChangeSummary(events);
165
+ }
166
+ emitter.emit('change', { events });
167
+ }
168
+
169
+ emitter.emit('build:start', { initial, events });
170
+
171
+ try {
172
+ const result = await runBuild(project, {
173
+ logTitle: initial ? 'ビルドが完了しました' : '再ビルドが完了しました',
174
+ includeOutput: initial,
175
+ });
176
+ emitter.emit('build:success', { initial, result, events });
177
+ return result;
178
+ } catch (err) {
179
+ const message = err && err.message ? err.message : String(err);
180
+
181
+ if (initial) {
182
+ console.error(message);
183
+ console.error(
184
+ `[JSKim] プロジェクト "${project.name}" の初回ビルドに失敗しました。ウォッチャーは開始します。`
185
+ );
186
+ } else {
187
+ const representative = events[events.length - 1] || null;
188
+ console.error(`[JSKim] 再ビルドに失敗しました`);
189
+ console.error(`プロジェクト: ${project.name}`);
190
+ if (representative) {
191
+ console.error(`イベント: ${representative.event}`);
192
+ console.error(`ファイル: ${representative.file}`);
193
+ }
194
+ if (events.length > 1) {
195
+ console.error(`変更数: ${events.length} ファイル`);
196
+ }
197
+ console.error(`原因: ${message}`);
198
+ console.error(
199
+ `[JSKim] ウォッチャーは継続中です。修正して保存すると再試行します。`
200
+ );
201
+ }
202
+
203
+ emitter.emit('build:failure', { initial, error: err, events });
204
+ return null;
205
+ }
206
+ }
207
+
208
+ async function close() {
209
+ if (stopping) {
210
+ return;
211
+ }
212
+ stopping = true;
213
+
214
+ if (debounceTimer) {
215
+ clearTimeout(debounceTimer);
216
+ debounceTimer = null;
217
+ }
218
+
219
+ if (chokidarWatcher) {
220
+ try {
221
+ await chokidarWatcher.close();
222
+ } catch {
223
+ // 終了時の close エラーは無視
224
+ }
225
+ chokidarWatcher = null;
226
+ }
227
+
228
+ if (buildPromise) {
229
+ try {
230
+ await buildPromise;
231
+ } catch {
232
+ // 再ビルドエラーは既にログ済み
233
+ }
234
+ }
235
+ }
236
+
237
+ emitter.start = start;
238
+ emitter.startWatching = startWatching;
239
+ emitter.close = close;
240
+ emitter.displayPaths = displayPaths;
241
+ emitter.debounceMs = debounceMs;
242
+
243
+ return emitter;
244
+ }
245
+
246
+ function logChangeSummary(events) {
247
+ if (!events || events.length === 0) {
248
+ console.log('[JSKim] 変更を検知しました');
249
+ return;
250
+ }
251
+
252
+ const last = events[events.length - 1];
253
+ console.log('[JSKim] 変更を検知しました');
254
+ console.log(`イベント: ${last.event}`);
255
+ console.log(`ファイル: ${last.file}`);
256
+ if (events.length > 1) {
257
+ console.log(`まとめた変更: ${events.length}`);
258
+ }
259
+ }
260
+
261
+ function toDisplayPath(filePath, workspaceRoot) {
262
+ const abs = path.resolve(filePath);
263
+ const rel = path.relative(workspaceRoot, abs);
264
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
265
+ return abs.split(path.sep).join('/');
266
+ }
267
+ return rel.split(path.sep).join('/');
268
+ }
269
+
270
+ module.exports = {
271
+ createProjectWatcher,
272
+ };
@@ -0,0 +1,351 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+ const fs = require('node:fs');
5
+ const fsp = require('node:fs/promises');
6
+ const path = require('node:path');
7
+ const { pipeline } = require('node:stream/promises');
8
+
9
+ const MIME_TYPES = {
10
+ '.html': 'text/html; charset=utf-8',
11
+ '.css': 'text/css; charset=utf-8',
12
+ '.js': 'application/javascript; charset=utf-8',
13
+ '.mjs': 'application/javascript; charset=utf-8',
14
+ '.cjs': 'application/javascript; charset=utf-8',
15
+ '.json': 'application/json; charset=utf-8',
16
+ '.svg': 'image/svg+xml',
17
+ '.png': 'image/png',
18
+ '.jpg': 'image/jpeg',
19
+ '.jpeg': 'image/jpeg',
20
+ '.gif': 'image/gif',
21
+ '.webp': 'image/webp',
22
+ '.ico': 'image/x-icon',
23
+ '.txt': 'text/plain; charset=utf-8',
24
+ '.xml': 'application/xml; charset=utf-8',
25
+ '.pdf': 'application/pdf',
26
+ '.woff': 'font/woff',
27
+ '.woff2': 'font/woff2',
28
+ '.ttf': 'font/ttf',
29
+ '.map': 'application/json; charset=utf-8',
30
+ };
31
+
32
+ /**
33
+ * outputDir をルートとするローカル静的 HTTP サーバーを作成します。
34
+ * serve / dev の両方から再利用します。
35
+ *
36
+ * @param {object} options
37
+ * @param {string} options.rootDir 提供ルート(通常は outputDir)
38
+ * @param {string} options.host
39
+ * @param {number} options.port
40
+ * @param {string} [options.projectName]
41
+ * @param {(html: string, meta: object) => string|Promise<string>} [options.transformHtml]
42
+ * @param {(req, res, meta: object) => boolean|Promise<boolean>} [options.handleInternalRequest]
43
+ * @returns {{ server: import('node:http').Server, start: Function, stop: Function, url: string }}
44
+ */
45
+ function createStaticServer({
46
+ rootDir,
47
+ host,
48
+ port,
49
+ projectName,
50
+ transformHtml,
51
+ handleInternalRequest,
52
+ }) {
53
+ const rootResolved = path.resolve(rootDir);
54
+ const displayName = projectName || '';
55
+
56
+ const server = http.createServer((req, res) => {
57
+ handleRequest(req, res, {
58
+ rootDir: rootResolved,
59
+ projectName: displayName,
60
+ transformHtml,
61
+ handleInternalRequest,
62
+ }).catch((err) => {
63
+ const message = err && err.message ? err.message : String(err);
64
+ console.error(
65
+ `[JSKim] リクエスト処理中にエラーが発生しました。\n` +
66
+ (displayName ? `プロジェクト: ${displayName}\n` : '') +
67
+ `原因: ${message}`
68
+ );
69
+ if (!res.headersSent) {
70
+ sendText(res, 500, 'ファイルの読み込み中にエラーが発生しました。\n');
71
+ } else {
72
+ res.destroy();
73
+ }
74
+ });
75
+ });
76
+
77
+ const url = `http://${host}:${port}/`;
78
+
79
+ function start() {
80
+ return new Promise((resolve, reject) => {
81
+ const onError = (err) => {
82
+ server.off('listening', onListening);
83
+ reject(err);
84
+ };
85
+ const onListening = () => {
86
+ server.off('error', onError);
87
+ resolve({
88
+ host,
89
+ port,
90
+ url,
91
+ rootDir: rootResolved,
92
+ });
93
+ };
94
+
95
+ server.once('error', onError);
96
+ server.once('listening', onListening);
97
+ server.listen(port, host);
98
+ });
99
+ }
100
+
101
+ function stop() {
102
+ return new Promise((resolve, reject) => {
103
+ if (!server.listening) {
104
+ resolve();
105
+ return;
106
+ }
107
+ server.close((err) => {
108
+ if (err) {
109
+ reject(err);
110
+ return;
111
+ }
112
+ resolve();
113
+ });
114
+ });
115
+ }
116
+
117
+ return { server, start, stop, url, rootDir: rootResolved };
118
+ }
119
+
120
+ async function handleRequest(req, res, context) {
121
+ const method = (req.method || 'GET').toUpperCase();
122
+
123
+ let pathname;
124
+ try {
125
+ const base = 'http://127.0.0.1';
126
+ const parsed = new URL(req.url || '/', base);
127
+ pathname = decodeURIComponent(parsed.pathname);
128
+ } catch {
129
+ sendText(res, 400, 'リクエストURLが不正です。\n');
130
+ return;
131
+ }
132
+
133
+ const normalizedPath = pathname.replace(/\\/g, '/');
134
+
135
+ if (typeof context.handleInternalRequest === 'function') {
136
+ const handled = await context.handleInternalRequest(req, res, {
137
+ pathname: normalizedPath,
138
+ method,
139
+ });
140
+ if (handled) {
141
+ return;
142
+ }
143
+ }
144
+
145
+ if (method !== 'GET' && method !== 'HEAD') {
146
+ res.setHeader('Allow', 'GET, HEAD');
147
+ sendText(res, 405, 'このHTTPメソッドは使用できません。\n');
148
+ return;
149
+ }
150
+
151
+ const relativeUrlPath = normalizedPath.replace(/^\/+/, '');
152
+
153
+ let candidatePath;
154
+ if (relativeUrlPath === '' || normalizedPath.endsWith('/')) {
155
+ candidatePath = path.join(
156
+ context.rootDir,
157
+ relativeUrlPath,
158
+ 'index.html'
159
+ );
160
+ } else {
161
+ candidatePath = path.join(context.rootDir, relativeUrlPath);
162
+ }
163
+
164
+ const safePath = await resolveSafePath(context.rootDir, candidatePath);
165
+ if (!safePath) {
166
+ sendText(res, 404, 'ファイルが見つかりません。\n');
167
+ return;
168
+ }
169
+
170
+ let stat;
171
+ try {
172
+ stat = await fsp.stat(safePath);
173
+ } catch {
174
+ sendText(res, 404, 'ファイルが見つかりません。\n');
175
+ return;
176
+ }
177
+
178
+ if (stat.isDirectory()) {
179
+ const indexPath = path.join(safePath, 'index.html');
180
+ const safeIndex = await resolveSafePath(context.rootDir, indexPath);
181
+ if (!safeIndex) {
182
+ sendText(res, 404, 'ファイルが見つかりません。\n');
183
+ return;
184
+ }
185
+ try {
186
+ const indexStat = await fsp.stat(safeIndex);
187
+ if (!indexStat.isFile()) {
188
+ sendText(res, 404, 'ファイルが見つかりません。\n');
189
+ return;
190
+ }
191
+ await sendFile(req, res, safeIndex, indexStat, method, context);
192
+ } catch {
193
+ sendText(res, 404, 'ファイルが見つかりません。\n');
194
+ }
195
+ return;
196
+ }
197
+
198
+ if (!stat.isFile()) {
199
+ sendText(res, 404, 'ファイルが見つかりません。\n');
200
+ return;
201
+ }
202
+
203
+ await sendFile(req, res, safePath, stat, method, context);
204
+ }
205
+
206
+ async function resolveSafePath(rootDir, candidatePath) {
207
+ const root = path.resolve(rootDir);
208
+ const resolvedCandidate = path.resolve(candidatePath);
209
+
210
+ if (!isInsideOrSame(root, resolvedCandidate)) {
211
+ return null;
212
+ }
213
+
214
+ try {
215
+ const realRoot = await fsp.realpath(root);
216
+ let realCandidate;
217
+ try {
218
+ realCandidate = await fsp.realpath(resolvedCandidate);
219
+ } catch (err) {
220
+ if (err && err.code === 'ENOENT') {
221
+ const parent = path.dirname(resolvedCandidate);
222
+ const base = path.basename(resolvedCandidate);
223
+ let realParent;
224
+ try {
225
+ realParent = await fsp.realpath(parent);
226
+ } catch {
227
+ return null;
228
+ }
229
+ realCandidate = path.join(realParent, base);
230
+ } else {
231
+ return null;
232
+ }
233
+ }
234
+
235
+ if (!isInsideOrSame(realRoot, realCandidate)) {
236
+ return null;
237
+ }
238
+
239
+ return realCandidate;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ async function sendFile(req, res, filePath, stat, method, context) {
246
+ const ext = path.extname(filePath).toLowerCase();
247
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
248
+ const shouldTransform =
249
+ typeof context.transformHtml === 'function' && ext === '.html';
250
+
251
+ if (shouldTransform) {
252
+ let html;
253
+ try {
254
+ html = await fsp.readFile(filePath, 'utf8');
255
+ } catch (err) {
256
+ const message = err && err.message ? err.message : String(err);
257
+ console.error(
258
+ `[JSKim] ファイルの読み込みに失敗しました。\n` +
259
+ `原因: ${message}`
260
+ );
261
+ sendText(res, 500, 'ファイルの読み込み中にエラーが発生しました。\n');
262
+ return;
263
+ }
264
+
265
+ const transformed = await context.transformHtml(html, {
266
+ filePath,
267
+ req,
268
+ method,
269
+ });
270
+ const body = Buffer.from(
271
+ typeof transformed === 'string' ? transformed : html,
272
+ 'utf8'
273
+ );
274
+
275
+ res.statusCode = 200;
276
+ res.setHeader('Content-Type', contentType);
277
+ res.setHeader('Content-Length', String(body.length));
278
+ res.setHeader('Cache-Control', 'no-store');
279
+
280
+ if (method === 'HEAD') {
281
+ res.end();
282
+ return;
283
+ }
284
+
285
+ res.end(body);
286
+ return;
287
+ }
288
+
289
+ res.statusCode = 200;
290
+ res.setHeader('Content-Type', contentType);
291
+ res.setHeader('Content-Length', String(stat.size));
292
+ res.setHeader('Cache-Control', 'no-store');
293
+
294
+ if (method === 'HEAD') {
295
+ res.end();
296
+ return;
297
+ }
298
+
299
+ const stream = fs.createReadStream(filePath);
300
+ stream.on('error', (err) => {
301
+ const message = err && err.message ? err.message : String(err);
302
+ console.error(
303
+ `[JSKim] ファイルの読み込みに失敗しました。\n` +
304
+ `原因: ${message}`
305
+ );
306
+ if (!res.headersSent) {
307
+ sendText(res, 500, 'ファイルの読み込み中にエラーが発生しました。\n');
308
+ } else {
309
+ res.destroy();
310
+ }
311
+ });
312
+
313
+ try {
314
+ await pipeline(stream, res);
315
+ } catch (err) {
316
+ if (err && err.code === 'ERR_STREAM_PREMATURE_CLOSE') {
317
+ return;
318
+ }
319
+ }
320
+ }
321
+
322
+ function sendText(res, statusCode, body) {
323
+ const payload = Buffer.from(body, 'utf8');
324
+ res.statusCode = statusCode;
325
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
326
+ res.setHeader('Content-Length', String(payload.length));
327
+ res.setHeader('Cache-Control', 'no-store');
328
+ res.end(payload);
329
+ }
330
+
331
+ function samePath(a, b) {
332
+ const na = path.resolve(a);
333
+ const nb = path.resolve(b);
334
+ if (process.platform === 'win32') {
335
+ return na.toLowerCase() === nb.toLowerCase();
336
+ }
337
+ return na === nb;
338
+ }
339
+
340
+ function isInsideOrSame(parent, child) {
341
+ if (samePath(parent, child)) {
342
+ return true;
343
+ }
344
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
345
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
346
+ }
347
+
348
+ module.exports = {
349
+ createStaticServer,
350
+ MIME_TYPES,
351
+ };
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+
5
+ const CONFIG_FILENAME = 'jskim.config.js';
6
+
7
+ /**
8
+ * ワークスペースルートから jskim.config.js を読み込みます。
9
+ * @param {string} workspaceRoot
10
+ * @returns {object}
11
+ */
12
+ function loadConfig(workspaceRoot) {
13
+ const configPath = path.join(workspaceRoot, CONFIG_FILENAME);
14
+
15
+ let config;
16
+ try {
17
+ // 同一プロセス内の再実行でも最新設定を読むためキャッシュを消す
18
+ delete require.cache[require.resolve(configPath)];
19
+ config = require(configPath);
20
+ } catch (err) {
21
+ if (err && err.code === 'MODULE_NOT_FOUND') {
22
+ const missingConfig =
23
+ typeof err.message === 'string' &&
24
+ err.message.includes(CONFIG_FILENAME);
25
+
26
+ if (missingConfig) {
27
+ throw new Error(
28
+ `[JSKim] 設定ファイルが見つかりません: ${configPath}\n` +
29
+ `ワークスペースルートに ${CONFIG_FILENAME} を作成してください。`
30
+ );
31
+ }
32
+ }
33
+
34
+ throw new Error(
35
+ `[JSKim] 設定ファイルの読み込みに失敗しました: ${configPath}\n` +
36
+ `原因: ${err && err.message ? err.message : String(err)}`
37
+ );
38
+ }
39
+
40
+ if (!config || typeof config !== 'object') {
41
+ throw new Error(
42
+ `[JSKim] 設定が不正です: ${configPath}\n` +
43
+ `原因: module.exports はオブジェクトである必要があります。`
44
+ );
45
+ }
46
+
47
+ if (!config.projects || typeof config.projects !== 'object') {
48
+ throw new Error(
49
+ `[JSKim] 設定が不正です: projects が無い、またはオブジェクトではありません。\n` +
50
+ `設定: ${configPath}`
51
+ );
52
+ }
53
+
54
+ return {
55
+ config,
56
+ configPath,
57
+ workspaceRoot,
58
+ };
59
+ }
60
+
61
+ module.exports = {
62
+ loadConfig,
63
+ CONFIG_FILENAME,
64
+ };