eprec 0.0.1 → 1.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/LICENSE +21 -0
- package/README.md +122 -29
- package/app/assets/styles.css +129 -0
- package/app/client/app.tsx +37 -0
- package/app/client/counter.tsx +22 -0
- package/app/client/entry.tsx +8 -0
- package/app/components/layout.tsx +37 -0
- package/app/config/env.ts +31 -0
- package/app/config/import-map.ts +9 -0
- package/app/config/init-env.ts +3 -0
- package/app/config/routes.ts +5 -0
- package/app/helpers/render.ts +6 -0
- package/app/router.tsx +102 -0
- package/app/routes/index.tsx +50 -0
- package/app-server.ts +60 -0
- package/cli.ts +173 -0
- package/package.json +46 -7
- package/process-course/chapter-processor.ts +1037 -0
- package/process-course/cli.ts +236 -0
- package/process-course/config.ts +50 -0
- package/process-course/edits/cli.ts +167 -0
- package/process-course/edits/combined-video-editor.ts +316 -0
- package/process-course/edits/edit-workspace.ts +90 -0
- package/process-course/edits/index.ts +20 -0
- package/process-course/edits/regenerate-transcript.ts +84 -0
- package/process-course/edits/remove-ranges.test.ts +36 -0
- package/process-course/edits/remove-ranges.ts +287 -0
- package/process-course/edits/timestamp-refinement.test.ts +25 -0
- package/process-course/edits/timestamp-refinement.ts +172 -0
- package/process-course/edits/transcript-diff.test.ts +105 -0
- package/process-course/edits/transcript-diff.ts +214 -0
- package/process-course/edits/transcript-output.test.ts +50 -0
- package/process-course/edits/transcript-output.ts +36 -0
- package/process-course/edits/types.ts +26 -0
- package/process-course/edits/video-editor.ts +246 -0
- package/process-course/errors.test.ts +63 -0
- package/process-course/errors.ts +82 -0
- package/process-course/ffmpeg.ts +449 -0
- package/process-course/jarvis-commands/handlers.ts +71 -0
- package/process-course/jarvis-commands/index.ts +14 -0
- package/process-course/jarvis-commands/parser.test.ts +348 -0
- package/process-course/jarvis-commands/parser.ts +257 -0
- package/process-course/jarvis-commands/types.ts +46 -0
- package/process-course/jarvis-commands/windows.ts +254 -0
- package/process-course/logging.ts +24 -0
- package/process-course/paths.test.ts +59 -0
- package/process-course/paths.ts +53 -0
- package/process-course/summary.test.ts +209 -0
- package/process-course/summary.ts +210 -0
- package/process-course/types.ts +85 -0
- package/process-course/utils/audio-analysis.test.ts +348 -0
- package/process-course/utils/audio-analysis.ts +463 -0
- package/process-course/utils/chapter-selection.test.ts +307 -0
- package/process-course/utils/chapter-selection.ts +136 -0
- package/process-course/utils/file-utils.test.ts +83 -0
- package/process-course/utils/file-utils.ts +57 -0
- package/process-course/utils/filename.test.ts +27 -0
- package/process-course/utils/filename.ts +12 -0
- package/process-course/utils/time-ranges.test.ts +221 -0
- package/process-course/utils/time-ranges.ts +86 -0
- package/process-course/utils/transcript.test.ts +257 -0
- package/process-course/utils/transcript.ts +86 -0
- package/process-course/utils/video-editing.ts +44 -0
- package/process-course-video.ts +389 -0
- package/public/robots.txt +2 -0
- package/server/bundling.ts +210 -0
- package/speech-detection.ts +355 -0
- package/utils.ts +138 -0
- package/whispercpp-transcribe.ts +343 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { mkdir } from 'node:fs/promises'
|
|
4
|
+
import { ensureFfmpegAvailable, getChapters } from './process-course/ffmpeg'
|
|
5
|
+
import { logInfo } from './process-course/logging'
|
|
6
|
+
import { parseCliArgs, type CliArgs } from './process-course/cli'
|
|
7
|
+
import { resolveChapterSelection } from './process-course/utils/chapter-selection'
|
|
8
|
+
import { removeDirIfEmpty } from './process-course/utils/file-utils'
|
|
9
|
+
import { writeJarvisLogs, writeSummaryLogs } from './process-course/summary'
|
|
10
|
+
import {
|
|
11
|
+
processChapter,
|
|
12
|
+
type ChapterProcessingOptions,
|
|
13
|
+
} from './process-course/chapter-processor'
|
|
14
|
+
import type {
|
|
15
|
+
JarvisEdit,
|
|
16
|
+
JarvisNote,
|
|
17
|
+
JarvisWarning,
|
|
18
|
+
ProcessedChapterInfo,
|
|
19
|
+
EditWorkspaceInfo,
|
|
20
|
+
} from './process-course/types'
|
|
21
|
+
import { formatSeconds } from './utils'
|
|
22
|
+
import { checkSegmentHasSpeech } from './speech-detection'
|
|
23
|
+
|
|
24
|
+
interface ProcessingSummary {
|
|
25
|
+
totalSelected: number
|
|
26
|
+
processed: number
|
|
27
|
+
skippedShortInitial: number
|
|
28
|
+
skippedShortTrimmed: number
|
|
29
|
+
skippedTranscription: number
|
|
30
|
+
fallbackNotes: number
|
|
31
|
+
logsWritten: number
|
|
32
|
+
jarvisWarnings: number
|
|
33
|
+
editsPending: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ProcessCourseOptions = Omit<CliArgs, 'shouldExit'>
|
|
37
|
+
|
|
38
|
+
export async function runProcessCourse(options: ProcessCourseOptions) {
|
|
39
|
+
const {
|
|
40
|
+
inputPaths,
|
|
41
|
+
outputDir,
|
|
42
|
+
minChapterDurationSeconds,
|
|
43
|
+
dryRun,
|
|
44
|
+
keepIntermediates,
|
|
45
|
+
writeLogs,
|
|
46
|
+
chapterSelection,
|
|
47
|
+
enableTranscription,
|
|
48
|
+
whisperModelPath,
|
|
49
|
+
whisperLanguage,
|
|
50
|
+
whisperBinaryPath,
|
|
51
|
+
} = options
|
|
52
|
+
|
|
53
|
+
await ensureFfmpegAvailable()
|
|
54
|
+
|
|
55
|
+
// Process each input file in turn
|
|
56
|
+
for (const inputPath of inputPaths) {
|
|
57
|
+
// Determine output directory for this file
|
|
58
|
+
let fileOutputDir: string
|
|
59
|
+
if (outputDir) {
|
|
60
|
+
// If only one input file, use output directory as-is
|
|
61
|
+
// Otherwise, create a subdirectory for each file
|
|
62
|
+
fileOutputDir =
|
|
63
|
+
inputPaths.length === 1
|
|
64
|
+
? outputDir
|
|
65
|
+
: path.join(outputDir, path.parse(inputPath).name)
|
|
66
|
+
} else {
|
|
67
|
+
// Default: create directory next to input file
|
|
68
|
+
fileOutputDir = path.join(
|
|
69
|
+
path.dirname(inputPath),
|
|
70
|
+
path.parse(inputPath).name,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await processInputFile({
|
|
75
|
+
inputPath,
|
|
76
|
+
outputDir: fileOutputDir,
|
|
77
|
+
minChapterDurationSeconds,
|
|
78
|
+
dryRun,
|
|
79
|
+
keepIntermediates,
|
|
80
|
+
writeLogs,
|
|
81
|
+
chapterSelection,
|
|
82
|
+
enableTranscription,
|
|
83
|
+
whisperModelPath,
|
|
84
|
+
whisperLanguage,
|
|
85
|
+
whisperBinaryPath,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runProcessCourseCli(rawArgs?: string[]) {
|
|
91
|
+
const parsedArgs = parseCliArgs(rawArgs)
|
|
92
|
+
if (parsedArgs.shouldExit) {
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await runProcessCourse(parsedArgs)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function processInputFile(options: {
|
|
100
|
+
inputPath: string
|
|
101
|
+
outputDir: string
|
|
102
|
+
minChapterDurationSeconds: number
|
|
103
|
+
dryRun: boolean
|
|
104
|
+
keepIntermediates: boolean
|
|
105
|
+
writeLogs: boolean
|
|
106
|
+
chapterSelection: import('./process-course/types').ChapterSelection | null
|
|
107
|
+
enableTranscription: boolean
|
|
108
|
+
whisperModelPath: string
|
|
109
|
+
whisperLanguage: string
|
|
110
|
+
whisperBinaryPath: string | undefined
|
|
111
|
+
}) {
|
|
112
|
+
const {
|
|
113
|
+
inputPath,
|
|
114
|
+
outputDir,
|
|
115
|
+
minChapterDurationSeconds,
|
|
116
|
+
dryRun,
|
|
117
|
+
keepIntermediates,
|
|
118
|
+
writeLogs,
|
|
119
|
+
chapterSelection,
|
|
120
|
+
enableTranscription,
|
|
121
|
+
whisperModelPath,
|
|
122
|
+
whisperLanguage,
|
|
123
|
+
whisperBinaryPath,
|
|
124
|
+
} = options
|
|
125
|
+
|
|
126
|
+
const tmpDir = path.join(outputDir, '.tmp')
|
|
127
|
+
|
|
128
|
+
const inputFile = Bun.file(inputPath)
|
|
129
|
+
if (!(await inputFile.exists())) {
|
|
130
|
+
throw new Error(`Input file not found: ${inputPath}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!dryRun) {
|
|
134
|
+
await mkdir(outputDir, { recursive: true })
|
|
135
|
+
await mkdir(tmpDir, { recursive: true })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const chapters = await getChapters(inputPath)
|
|
139
|
+
if (chapters.length === 0) {
|
|
140
|
+
throw new Error('No chapters found. The input must contain chapters.')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const chapterIndexes = chapterSelection
|
|
144
|
+
? resolveChapterSelection(chapterSelection, chapters.length)
|
|
145
|
+
: null
|
|
146
|
+
|
|
147
|
+
logStartupInfo({
|
|
148
|
+
inputPath,
|
|
149
|
+
chaptersCount: chapters.length,
|
|
150
|
+
chapterIndexes,
|
|
151
|
+
minChapterDurationSeconds,
|
|
152
|
+
dryRun,
|
|
153
|
+
keepIntermediates,
|
|
154
|
+
writeLogs,
|
|
155
|
+
enableTranscription,
|
|
156
|
+
whisperModelPath,
|
|
157
|
+
whisperLanguage,
|
|
158
|
+
whisperBinaryPath,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
chapters.forEach((chapter) => {
|
|
162
|
+
logInfo(
|
|
163
|
+
`- [${chapter.index + 1}] ${chapter.title} (${formatSeconds(chapter.start)} -> ${formatSeconds(chapter.end)})`,
|
|
164
|
+
)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const selectedChapters = chapterIndexes
|
|
168
|
+
? chapters.filter((chapter) => chapterIndexes.includes(chapter.index))
|
|
169
|
+
: chapters
|
|
170
|
+
|
|
171
|
+
const summary: ProcessingSummary = {
|
|
172
|
+
totalSelected: selectedChapters.length,
|
|
173
|
+
processed: 0,
|
|
174
|
+
skippedShortInitial: 0,
|
|
175
|
+
skippedShortTrimmed: 0,
|
|
176
|
+
skippedTranscription: 0,
|
|
177
|
+
fallbackNotes: 0,
|
|
178
|
+
logsWritten: 0,
|
|
179
|
+
jarvisWarnings: 0,
|
|
180
|
+
editsPending: 0,
|
|
181
|
+
}
|
|
182
|
+
const summaryDetails: string[] = []
|
|
183
|
+
const jarvisWarnings: JarvisWarning[] = []
|
|
184
|
+
const jarvisEdits: JarvisEdit[] = []
|
|
185
|
+
const jarvisNotes: JarvisNote[] = []
|
|
186
|
+
const editWorkspaces: EditWorkspaceInfo[] = []
|
|
187
|
+
|
|
188
|
+
const processingOptions: ChapterProcessingOptions = {
|
|
189
|
+
inputPath,
|
|
190
|
+
outputDir,
|
|
191
|
+
tmpDir,
|
|
192
|
+
minChapterDurationSeconds,
|
|
193
|
+
enableTranscription,
|
|
194
|
+
whisperModelPath,
|
|
195
|
+
whisperLanguage,
|
|
196
|
+
whisperBinaryPath,
|
|
197
|
+
keepIntermediates,
|
|
198
|
+
writeLogs,
|
|
199
|
+
dryRun,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Track processed chapters that have speech (for combine logic)
|
|
203
|
+
const processedChaptersWithSpeech: ProcessedChapterInfo[] = []
|
|
204
|
+
let previousProcessedChapter: ProcessedChapterInfo | null = null
|
|
205
|
+
|
|
206
|
+
for (const chapter of selectedChapters) {
|
|
207
|
+
// Determine which chapter to combine with
|
|
208
|
+
// Always use the most recent processed chapter with speech (if any)
|
|
209
|
+
const chapterToCombineWith: ProcessedChapterInfo | null =
|
|
210
|
+
processedChaptersWithSpeech.at(-1) ?? null
|
|
211
|
+
// If previousProcessedChapter exists but is different, log that we're skipping it
|
|
212
|
+
if (
|
|
213
|
+
chapterToCombineWith !== null &&
|
|
214
|
+
previousProcessedChapter !== null &&
|
|
215
|
+
previousProcessedChapter !== chapterToCombineWith
|
|
216
|
+
) {
|
|
217
|
+
logInfo(
|
|
218
|
+
`Previous chapter ${previousProcessedChapter.chapter.index + 1} has no speech. Using chapter ${chapterToCombineWith.chapter.index + 1} for combine instead.`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const result = await processChapter(chapter, {
|
|
223
|
+
...processingOptions,
|
|
224
|
+
previousProcessedChapter: chapterToCombineWith,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Update summary based on result
|
|
228
|
+
if (result.status === 'processed') {
|
|
229
|
+
summary.processed += 1
|
|
230
|
+
} else {
|
|
231
|
+
switch (result.skipReason) {
|
|
232
|
+
case 'short-initial':
|
|
233
|
+
summary.skippedShortInitial += 1
|
|
234
|
+
summaryDetails.push(
|
|
235
|
+
`Skipped chapter ${chapter.index + 1} (${formatSeconds(chapter.end - chapter.start)} < ${formatSeconds(minChapterDurationSeconds)}).`,
|
|
236
|
+
)
|
|
237
|
+
break
|
|
238
|
+
case 'short-trimmed':
|
|
239
|
+
summary.skippedShortTrimmed += 1
|
|
240
|
+
summaryDetails.push(
|
|
241
|
+
`Skipped chapter ${chapter.index + 1} (trimmed duration too short).`,
|
|
242
|
+
)
|
|
243
|
+
break
|
|
244
|
+
case 'transcript':
|
|
245
|
+
case 'bad-take':
|
|
246
|
+
summary.skippedTranscription += 1
|
|
247
|
+
summaryDetails.push(
|
|
248
|
+
`Skipped chapter ${chapter.index + 1} (${result.skipReason}).`,
|
|
249
|
+
)
|
|
250
|
+
break
|
|
251
|
+
case 'dry-run':
|
|
252
|
+
// Dry run counts as processed
|
|
253
|
+
break
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (result.logWritten) {
|
|
258
|
+
summary.logsWritten += 1
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (result.fallbackNote) {
|
|
262
|
+
summary.fallbackNotes += 1
|
|
263
|
+
summaryDetails.push(
|
|
264
|
+
`Fallback for chapter ${chapter.index + 1}: ${result.fallbackNote}`,
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (result.jarvisWarning) {
|
|
269
|
+
jarvisWarnings.push(result.jarvisWarning)
|
|
270
|
+
summary.jarvisWarnings += 1
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (result.jarvisEdit) {
|
|
274
|
+
jarvisEdits.push(result.jarvisEdit)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (result.jarvisNotes) {
|
|
278
|
+
jarvisNotes.push(...result.jarvisNotes)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (result.editWorkspace) {
|
|
282
|
+
editWorkspaces.push(result.editWorkspace)
|
|
283
|
+
summary.editsPending += 1
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Update previous processed chapter for combine logic
|
|
287
|
+
if (result.status === 'processed' && result.processedInfo) {
|
|
288
|
+
previousProcessedChapter = result.processedInfo
|
|
289
|
+
|
|
290
|
+
// If we combined with a chapter, the combined output replaces that chapter in the list
|
|
291
|
+
if (chapterToCombineWith) {
|
|
292
|
+
const combineIndex = processedChaptersWithSpeech.findIndex(
|
|
293
|
+
(ch) => ch === chapterToCombineWith,
|
|
294
|
+
)
|
|
295
|
+
if (combineIndex >= 0) {
|
|
296
|
+
// Replace the combined chapter with the new combined output
|
|
297
|
+
processedChaptersWithSpeech[combineIndex] = result.processedInfo
|
|
298
|
+
} else {
|
|
299
|
+
// Shouldn't happen, but if it does, add the combined result
|
|
300
|
+
processedChaptersWithSpeech.push(result.processedInfo)
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
// Not a combine - check if this chapter has speech and add to list if it does
|
|
304
|
+
const hasSpeech = await checkSegmentHasSpeech(
|
|
305
|
+
result.processedInfo.outputPath,
|
|
306
|
+
result.processedInfo.processedDuration,
|
|
307
|
+
)
|
|
308
|
+
if (hasSpeech) {
|
|
309
|
+
processedChaptersWithSpeech.push(result.processedInfo)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Always write jarvis logs (summary information)
|
|
316
|
+
await writeJarvisLogs({
|
|
317
|
+
outputDir,
|
|
318
|
+
inputPath,
|
|
319
|
+
jarvisWarnings,
|
|
320
|
+
jarvisEdits,
|
|
321
|
+
jarvisNotes,
|
|
322
|
+
editWorkspaces,
|
|
323
|
+
dryRun,
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// Only write detailed summary log when writeLogs is enabled
|
|
327
|
+
if (writeLogs) {
|
|
328
|
+
await writeSummaryLogs({
|
|
329
|
+
tmpDir,
|
|
330
|
+
outputDir,
|
|
331
|
+
inputPath,
|
|
332
|
+
summary,
|
|
333
|
+
summaryDetails,
|
|
334
|
+
jarvisWarnings,
|
|
335
|
+
jarvisEdits,
|
|
336
|
+
editWorkspaces,
|
|
337
|
+
dryRun,
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (!dryRun) {
|
|
342
|
+
await removeDirIfEmpty(tmpDir)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function logStartupInfo(options: {
|
|
347
|
+
inputPath: string
|
|
348
|
+
chaptersCount: number
|
|
349
|
+
chapterIndexes: number[] | null
|
|
350
|
+
minChapterDurationSeconds: number
|
|
351
|
+
dryRun: boolean
|
|
352
|
+
keepIntermediates: boolean
|
|
353
|
+
writeLogs: boolean
|
|
354
|
+
enableTranscription: boolean
|
|
355
|
+
whisperModelPath: string
|
|
356
|
+
whisperLanguage: string
|
|
357
|
+
whisperBinaryPath: string | undefined
|
|
358
|
+
}) {
|
|
359
|
+
logInfo(`Processing: ${options.inputPath}`)
|
|
360
|
+
logInfo(`Chapters found: ${options.chaptersCount}`)
|
|
361
|
+
if (options.chapterIndexes) {
|
|
362
|
+
logInfo(
|
|
363
|
+
`Filtering to chapters: ${options.chapterIndexes.map((index) => index + 1).join(', ')}`,
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
logInfo(
|
|
367
|
+
`Skipping chapters shorter than ${formatSeconds(options.minChapterDurationSeconds)}.`,
|
|
368
|
+
)
|
|
369
|
+
if (options.dryRun) {
|
|
370
|
+
logInfo('Dry run enabled; no files will be written.')
|
|
371
|
+
} else if (options.keepIntermediates) {
|
|
372
|
+
logInfo('Keeping intermediate files for debugging.')
|
|
373
|
+
}
|
|
374
|
+
if (options.writeLogs) {
|
|
375
|
+
logInfo('Writing log files for skipped/fallback cases.')
|
|
376
|
+
}
|
|
377
|
+
if (options.enableTranscription) {
|
|
378
|
+
logInfo(
|
|
379
|
+
`Whisper transcription enabled (model: ${options.whisperModelPath}, language: ${options.whisperLanguage}, binary: ${options.whisperBinaryPath}).`,
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (import.meta.main) {
|
|
385
|
+
runProcessCourseCli().catch((error) => {
|
|
386
|
+
console.error(`[error] ${error instanceof Error ? error.message : error}`)
|
|
387
|
+
process.exit(1)
|
|
388
|
+
})
|
|
389
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
type PackageJson = {
|
|
4
|
+
exports?: Record<string, { default?: string; types?: string } | string>
|
|
5
|
+
module?: string
|
|
6
|
+
main?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function resolvePackageExport(
|
|
10
|
+
specifier: string,
|
|
11
|
+
rootDir: string,
|
|
12
|
+
): Promise<string | null> {
|
|
13
|
+
const parts = specifier.split('/')
|
|
14
|
+
let packageName: string
|
|
15
|
+
let subpathParts: string[]
|
|
16
|
+
|
|
17
|
+
if (specifier.startsWith('@')) {
|
|
18
|
+
if (parts.length < 2) return null
|
|
19
|
+
packageName = `${parts[0]}/${parts[1]}`
|
|
20
|
+
subpathParts = parts.slice(2)
|
|
21
|
+
} else {
|
|
22
|
+
if (parts.length === 0 || !parts[0]) return null
|
|
23
|
+
packageName = parts[0]
|
|
24
|
+
subpathParts = parts.slice(1)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const subpath = subpathParts.length > 0 ? `./${subpathParts.join('/')}` : '.'
|
|
28
|
+
const packageDir = path.join(rootDir, 'node_modules', packageName)
|
|
29
|
+
const packageJsonPath = path.join(packageDir, 'package.json')
|
|
30
|
+
const packageJsonFile = Bun.file(packageJsonPath)
|
|
31
|
+
|
|
32
|
+
if (!(await packageJsonFile.exists())) return null
|
|
33
|
+
|
|
34
|
+
const packageJson = JSON.parse(
|
|
35
|
+
await packageJsonFile.text(),
|
|
36
|
+
) as PackageJson
|
|
37
|
+
|
|
38
|
+
if (!packageJson.exports) {
|
|
39
|
+
const entryFile = packageJson.module || packageJson.main
|
|
40
|
+
if (entryFile) {
|
|
41
|
+
const entryPath = path.join(packageDir, entryFile)
|
|
42
|
+
if (await Bun.file(entryPath).exists()) return entryPath
|
|
43
|
+
}
|
|
44
|
+
const indexPath = path.join(packageDir, 'index.js')
|
|
45
|
+
return (await Bun.file(indexPath).exists()) ? indexPath : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const exportEntry = packageJson.exports[subpath]
|
|
49
|
+
if (!exportEntry) return null
|
|
50
|
+
|
|
51
|
+
const exportPath =
|
|
52
|
+
typeof exportEntry === 'string'
|
|
53
|
+
? exportEntry
|
|
54
|
+
: exportEntry.default
|
|
55
|
+
|
|
56
|
+
if (!exportPath) return null
|
|
57
|
+
|
|
58
|
+
const resolvedPath = path.join(packageDir, exportPath)
|
|
59
|
+
return (await Bun.file(resolvedPath).exists()) ? resolvedPath : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const BUNDLING_CORS_HEADERS = {
|
|
63
|
+
'Access-Control-Allow-Origin': '*',
|
|
64
|
+
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
|
65
|
+
'Access-Control-Allow-Headers': 'Accept, Content-Type',
|
|
66
|
+
} as const
|
|
67
|
+
|
|
68
|
+
export function createBundlingRoutes(rootDir: string) {
|
|
69
|
+
const clientDir = path.resolve(rootDir, 'app', 'client')
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
'/app/client/*': async (request: Request) => {
|
|
73
|
+
if (request.method === 'OPTIONS') {
|
|
74
|
+
return new Response(null, {
|
|
75
|
+
status: 204,
|
|
76
|
+
headers: {
|
|
77
|
+
...BUNDLING_CORS_HEADERS,
|
|
78
|
+
'Access-Control-Max-Age': '86400',
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const url = new URL(request.url)
|
|
84
|
+
const reqPath = path.posix.normalize(url.pathname.replace(/^\/+/, ''))
|
|
85
|
+
const resolved = path.resolve(rootDir, reqPath)
|
|
86
|
+
|
|
87
|
+
if (!resolved.startsWith(clientDir + path.sep)) {
|
|
88
|
+
return new Response('Forbidden', {
|
|
89
|
+
status: 403,
|
|
90
|
+
headers: BUNDLING_CORS_HEADERS,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!resolved.endsWith('.ts') && !resolved.endsWith('.tsx')) {
|
|
95
|
+
return new Response('Not Found', {
|
|
96
|
+
status: 404,
|
|
97
|
+
headers: BUNDLING_CORS_HEADERS,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const entryFile = Bun.file(resolved)
|
|
102
|
+
if (!(await entryFile.exists())) {
|
|
103
|
+
return new Response('Not Found', {
|
|
104
|
+
status: 404,
|
|
105
|
+
headers: BUNDLING_CORS_HEADERS,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const buildResult = await Bun.build({
|
|
110
|
+
entrypoints: [resolved],
|
|
111
|
+
target: 'browser',
|
|
112
|
+
minify: Bun.env.NODE_ENV === 'production',
|
|
113
|
+
splitting: false,
|
|
114
|
+
format: 'esm',
|
|
115
|
+
sourcemap: Bun.env.NODE_ENV === 'production' ? 'none' : 'inline',
|
|
116
|
+
jsx: { importSource: 'remix/component' },
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
if (!buildResult.success) {
|
|
120
|
+
const errorMessage = buildResult.logs
|
|
121
|
+
.map((log) => log.message)
|
|
122
|
+
.join('\n')
|
|
123
|
+
return new Response(errorMessage || 'Build failed', {
|
|
124
|
+
status: 500,
|
|
125
|
+
headers: {
|
|
126
|
+
'Content-Type': 'text/plain',
|
|
127
|
+
...BUNDLING_CORS_HEADERS,
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const output = buildResult.outputs[0]
|
|
133
|
+
return new Response(output, {
|
|
134
|
+
headers: {
|
|
135
|
+
'Content-Type': 'application/javascript',
|
|
136
|
+
'Cache-Control':
|
|
137
|
+
Bun.env.NODE_ENV === 'production'
|
|
138
|
+
? 'public, max-age=31536000, immutable'
|
|
139
|
+
: 'no-cache',
|
|
140
|
+
...BUNDLING_CORS_HEADERS,
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
'/node_modules/*': async (request: Request) => {
|
|
146
|
+
if (request.method === 'OPTIONS') {
|
|
147
|
+
return new Response(null, {
|
|
148
|
+
status: 204,
|
|
149
|
+
headers: {
|
|
150
|
+
...BUNDLING_CORS_HEADERS,
|
|
151
|
+
'Access-Control-Max-Age': '86400',
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const url = new URL(request.url)
|
|
157
|
+
const specifier = url.pathname.replace('/node_modules/', '')
|
|
158
|
+
const filepath = await resolvePackageExport(specifier, rootDir)
|
|
159
|
+
|
|
160
|
+
if (!filepath) {
|
|
161
|
+
return new Response('Package not found', {
|
|
162
|
+
status: 404,
|
|
163
|
+
headers: BUNDLING_CORS_HEADERS,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const nodeModulesDir = path.resolve(rootDir, 'node_modules')
|
|
168
|
+
if (!filepath.startsWith(nodeModulesDir + path.sep)) {
|
|
169
|
+
return new Response('Forbidden', {
|
|
170
|
+
status: 403,
|
|
171
|
+
headers: BUNDLING_CORS_HEADERS,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const buildResult = await Bun.build({
|
|
176
|
+
entrypoints: [filepath],
|
|
177
|
+
target: 'browser',
|
|
178
|
+
minify: Bun.env.NODE_ENV === 'production',
|
|
179
|
+
splitting: false,
|
|
180
|
+
format: 'esm',
|
|
181
|
+
sourcemap: Bun.env.NODE_ENV === 'production' ? 'none' : 'inline',
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
if (!buildResult.success) {
|
|
185
|
+
const errorMessage = buildResult.logs
|
|
186
|
+
.map((log) => log.message)
|
|
187
|
+
.join('\n')
|
|
188
|
+
return new Response(errorMessage || 'Build failed', {
|
|
189
|
+
status: 500,
|
|
190
|
+
headers: {
|
|
191
|
+
'Content-Type': 'text/plain',
|
|
192
|
+
...BUNDLING_CORS_HEADERS,
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const output = buildResult.outputs[0]
|
|
198
|
+
return new Response(output, {
|
|
199
|
+
headers: {
|
|
200
|
+
'Content-Type': 'application/javascript',
|
|
201
|
+
'Cache-Control':
|
|
202
|
+
Bun.env.NODE_ENV === 'production'
|
|
203
|
+
? 'public, max-age=31536000, immutable'
|
|
204
|
+
: 'no-cache',
|
|
205
|
+
...BUNDLING_CORS_HEADERS,
|
|
206
|
+
},
|
|
207
|
+
})
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
}
|