pellicule 0.0.4 → 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.
package/bin/cli.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { parseArgs } from 'node:util'
4
- import { resolve, extname, basename, dirname } from 'node:path'
4
+ import { resolve, join, extname, basename, dirname } from 'node:path'
5
5
  import { existsSync, readFileSync } from 'node:fs'
6
6
  import { fileURLToPath } from 'node:url'
7
7
  import { renderToMp4 } from '../src/render.js'
8
8
  import { extractVideoConfig, resolveVideoConfig } from '../src/macros/define-video-config.js'
9
+ import { detectProject, readPelliculeConfig, resolveInputFile } from '../src/config/detect.js'
9
10
 
10
11
  // Read version from package.json
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -60,6 +61,13 @@ ${c.bold('OPTIONS')}
60
61
  ${c.info('--help')} Show this help message
61
62
  ${c.info('--version')} Show version number
62
63
 
64
+ ${c.bold('INTEGRATION OPTIONS')}
65
+ ${c.info('--server-url')} <url> Use a running dev server (BYOS mode)
66
+ ${c.info('--bundler')} <name> Force a bundler: vite or rsbuild
67
+ ${c.info('--config')} <file> Use a specific config file
68
+ ${c.info('--videos-dir')} <path> Custom directory for video components
69
+ ${c.info('--out-dir')} <path> Directory for rendered video output
70
+
63
71
  ${c.bold('COMPONENT CONFIG')}
64
72
  Use ${c.highlight('defineVideoConfig')} in your component to set defaults:
65
73
 
@@ -68,6 +76,22 @@ ${c.bold('COMPONENT CONFIG')}
68
76
  No import needed - it's a compiler macro like Vue's defineProps.
69
77
  Then just run: ${c.highlight('pellicule')} ${c.dim('(no flags needed!)')}
70
78
 
79
+ ${c.bold('PROJECT CONFIG')}
80
+ Set options once in package.json instead of passing CLI flags:
81
+
82
+ ${c.dim('{ "pellicule": { "serverUrl": "http://localhost:3000" } }')}
83
+
84
+ Supported keys: ${c.info('serverUrl')}, ${c.info('videosDir')}, ${c.info('outDir')}, ${c.info('bundler')}
85
+ Resolution: CLI flags > package.json > auto-detected > defaults
86
+
87
+ ${c.bold('AUTO-DETECTION')}
88
+ Pellicule reads your existing config files automatically:
89
+ ${c.dim('vite.config.js')} → Vite adapter
90
+ ${c.dim('rsbuild.config.js')} → Rsbuild adapter
91
+ ${c.dim('config/shipwright.js')} → Rsbuild adapter (boring stack)
92
+ ${c.dim('nuxt.config.ts')} → BYOS mode (defaults to localhost:3000)
93
+ ${c.dim('No config')} → Built-in Vite (zero config)
94
+
71
95
  ${c.bold('EXAMPLES')}
72
96
  ${c.dim('# Zero-config (uses defineVideoConfig from component)')}
73
97
  ${c.highlight('pellicule')}
@@ -84,6 +108,12 @@ ${c.bold('EXAMPLES')}
84
108
  ${c.dim('# Render only frames 100-200 (for faster iteration)')}
85
109
  ${c.highlight('pellicule')} Video.vue -r 100:200
86
110
 
111
+ ${c.dim('# Use with Nuxt (auto-detects, connects to localhost:3000)')}
112
+ ${c.highlight('pellicule')} InvoiceDemo
113
+
114
+ ${c.dim('# Force Rsbuild bundler')}
115
+ ${c.highlight('pellicule')} Video.vue --bundler rsbuild
116
+
87
117
  ${c.bold('DURATION HELPER')}
88
118
  frames = seconds * fps
89
119
  ${c.dim('3 seconds at 30fps = 90 frames')}
@@ -125,6 +155,11 @@ async function main() {
125
155
  height: { type: 'string', short: 'h' },
126
156
  range: { type: 'string', short: 'r' },
127
157
  audio: { type: 'string', short: 'a' },
158
+ 'server-url': { type: 'string' },
159
+ bundler: { type: 'string' },
160
+ config: { type: 'string' },
161
+ 'videos-dir': { type: 'string' },
162
+ 'out-dir': { type: 'string' },
128
163
  help: { type: 'boolean' },
129
164
  version: { type: 'boolean' }
130
165
  }
@@ -141,21 +176,37 @@ async function main() {
141
176
  process.exit(0)
142
177
  }
143
178
 
144
- // Default to Video.vue if no input provided
145
- const input = positionals[0] || 'Video.vue'
179
+ // ── Auto-detection ────────────────────────────────────────────────
180
+ const detected = detectProject()
181
+ const pkgConfig = readPelliculeConfig()
182
+
183
+ // Resolution: CLI flags > package.json "pellicule" key > auto-detected
184
+ const bundler = values.bundler || pkgConfig.bundler || detected.bundler
185
+ const configFile = values.config ? resolve(values.config) : detected.configFile
186
+ const videosDir = values['videos-dir'] ? resolve(values['videos-dir']) : pkgConfig.videosDir || detected.videosDir
187
+ const outDir = values['out-dir'] ? resolve(values['out-dir']) : pkgConfig.outDir || null
188
+ const serverUrl = values['server-url'] || pkgConfig.serverUrl || detected.defaultServerUrl || null
189
+ const projectType = detected.projectType
190
+
191
+ // Validate bundler flag
192
+ if (values.bundler && !['vite', 'rsbuild'].includes(values.bundler)) {
193
+ fail(`Unknown bundler: ${values.bundler}`, 'Supported bundlers: vite, rsbuild')
194
+ }
146
195
 
147
- // Try to resolve the input file, auto-appending .vue if needed
148
- let inputPath = resolve(input)
196
+ // ── Input file resolution ─────────────────────────────────────────
197
+ const input = positionals[0] || 'Video.vue'
198
+ const result = resolveInputFile(input, videosDir)
149
199
 
150
- if (!existsSync(inputPath) && !input.endsWith('.vue')) {
151
- const withVue = resolve(input + '.vue')
152
- if (existsSync(withVue)) {
153
- inputPath = withVue
154
- }
200
+ if (result.error) {
201
+ const searchedPaths = result.searched.map(p => ` - ${p}`).join('\n')
202
+ fail(result.error, `Looked in:\n${searchedPaths}`)
155
203
  }
156
204
 
157
- if (!existsSync(inputPath)) fail(`File not found: ${input}`)
158
- if (extname(inputPath) !== '.vue') fail(`Input must be a .vue file, got: ${extname(inputPath) || '(no extension)'}`)
205
+ const inputPath = result.resolved
206
+
207
+ if (extname(inputPath) !== '.vue') {
208
+ fail(`Input must be a .vue file, got: ${extname(inputPath) || '(no extension)'}`)
209
+ }
159
210
 
160
211
  // Extract config from component (if defineVideoConfig is used)
161
212
  const componentConfig = extractVideoConfig(inputPath)
@@ -185,7 +236,16 @@ async function main() {
185
236
  const durationInFrames = resolvedConfig.durationInFrames
186
237
  const width = resolvedConfig.width
187
238
  const height = resolvedConfig.height
188
- const output = values.output || './output.mp4'
239
+ let output
240
+ if (values.output) {
241
+ output = values.output
242
+ } else if (outDir) {
243
+ // Use component name as filename when outDir is configured
244
+ const componentName = basename(inputPath, '.vue')
245
+ output = join(outDir, `${componentName}.mp4`)
246
+ } else {
247
+ output = './output.mp4'
248
+ }
189
249
  const outputPath = resolve(output)
190
250
 
191
251
  // Parse optional range (start:end format)
@@ -222,6 +282,23 @@ async function main() {
222
282
  if (componentConfig) {
223
283
  console.log(` ${c.bold('Config')} ${c.highlight('defineVideoConfig')} ${c.dim('detected ✓')}`)
224
284
  }
285
+
286
+ // Show detected project info
287
+ if (projectType !== 'standalone') {
288
+ const projectLabels = {
289
+ laravel: 'Laravel',
290
+ vite: 'Vite',
291
+ rsbuild: 'Rsbuild',
292
+ shipwright: 'Boring Stack (Shipwright)',
293
+ nuxt: 'Nuxt',
294
+ quasar: 'Quasar'
295
+ }
296
+ console.log(` ${c.bold('Project')} ${c.highlight(projectLabels[projectType] || projectType)} ${c.dim('detected ✓')}`)
297
+ }
298
+ if (serverUrl) {
299
+ console.log(` ${c.bold('Server')} ${c.info(serverUrl)} ${c.dim('(BYOS)')}`)
300
+ }
301
+
225
302
  console.log(` ${c.bold('Output')} ${c.info(basename(outputPath))}`)
226
303
  console.log(` ${c.bold('Resolution')} ${width}x${height}`)
227
304
  console.log(` ${c.bold('Duration')} ${durationInFrames} frames @ ${fps}fps ${c.dim(`(${durationSeconds}s)`)}`)
@@ -239,6 +316,15 @@ async function main() {
239
316
  const clearLine = '\x1b[2K' // Clear entire line
240
317
  const cursorToStart = '\r' // Move cursor to start of line
241
318
 
319
+ // For Nuxt and Quasar projects, construct the /pellicule render page URL.
320
+ // - Nuxt: pellicule/nuxt module injects the page
321
+ // - Quasar: pellicule/quasar Vite plugin serves the page
322
+ let finalServerUrl = serverUrl
323
+ if ((projectType === 'nuxt' || projectType === 'quasar') && serverUrl) {
324
+ const componentName = basename(inputPath, '.vue')
325
+ finalServerUrl = `${serverUrl.replace(/\/$/, '')}/pellicule?component=${encodeURIComponent(componentName)}`
326
+ }
327
+
242
328
  try {
243
329
  // Render with progress callback
244
330
  await renderToMp4({
@@ -252,6 +338,10 @@ async function main() {
252
338
  output: outputPath,
253
339
  audio: audioPath,
254
340
  silent: true,
341
+ serverUrl: finalServerUrl,
342
+ bundler,
343
+ configFile,
344
+ projectType,
255
345
  onProgress: ({ frame, total, fps: currentFps }) => {
256
346
  // Clear line and print progress (stays on same line)
257
347
  process.stdout.write(clearLine + cursorToStart + formatProgress(frame, total, currentFps || fps))
@@ -280,6 +370,12 @@ async function main() {
280
370
  console.error()
281
371
  }
282
372
 
373
+ if (error.message.includes('Rsbuild')) {
374
+ console.error(c.warn(' Hint: Install @rsbuild/core and @rsbuild/plugin-vue'))
375
+ console.error(c.dim(' npm install -D @rsbuild/core @rsbuild/plugin-vue'))
376
+ console.error()
377
+ }
378
+
283
379
  process.exit(1)
284
380
  }
285
381
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pellicule",
3
- "version": "0.0.4",
3
+ "version": "0.1.0",
4
4
  "description": "Deterministic video rendering with Vue",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -9,7 +9,9 @@
9
9
  },
10
10
  "exports": {
11
11
  ".": "./src/index.js",
12
- "./render": "./src/render.js"
12
+ "./render": "./src/render.js",
13
+ "./nuxt": "./src/nuxt/module.js",
14
+ "./quasar": "./src/quasar/index.js"
13
15
  },
14
16
  "keywords": [
15
17
  "vue",
@@ -27,12 +29,28 @@
27
29
  "url": "https://github.com/sailscastshq/pellicule"
28
30
  },
29
31
  "dependencies": {
30
- "@vue/compiler-sfc": "^3.0.0",
31
32
  "@vitejs/plugin-vue": "^5.0.0",
32
33
  "playwright": "^1.40.0",
33
34
  "vite": "^5.0.0"
34
35
  },
35
36
  "peerDependencies": {
37
+ "@nuxt/kit": "^3.0.0",
38
+ "@rsbuild/core": "^1.0.0",
39
+ "@rsbuild/plugin-vue": "^1.0.0",
36
40
  "vue": "^3.0.0"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@rsbuild/core": {
44
+ "optional": true
45
+ },
46
+ "@rsbuild/plugin-vue": {
47
+ "optional": true
48
+ },
49
+ "@nuxt/kit": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "devDependencies": {
54
+ "@nuxt/kit": "^4.3.0"
37
55
  }
38
56
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Bundler-agnostic entry file generation.
3
+ *
4
+ * Both the Vite adapter and the Rsbuild adapter produce the same
5
+ * index.html + entry.js scaffold to mount the user's Vue component.
6
+ * This module owns that template so changes propagate everywhere.
7
+ */
8
+
9
+ import { writeFile, mkdir, rm } from 'fs/promises'
10
+ import { join, basename } from 'path'
11
+
12
+ /**
13
+ * Generate the entry HTML that wraps the video at the given dimensions.
14
+ *
15
+ * @param {object} options
16
+ * @param {number} options.width
17
+ * @param {number} options.height
18
+ * @returns {string}
19
+ */
20
+ export function generateHtml({ width = 1920, height = 1080 }) {
21
+ return `<!DOCTYPE html>
22
+ <html>
23
+ <head>
24
+ <meta charset="UTF-8">
25
+ <style>
26
+ * { margin: 0; padding: 0; box-sizing: border-box; }
27
+ html, body { width: ${width}px; height: ${height}px; overflow: hidden; }
28
+ #app { width: 100%; height: 100%; }
29
+ </style>
30
+ </head>
31
+ <body>
32
+ <div id="app"></div>
33
+ <script type="module" src="./entry.js"></script>
34
+ </body>
35
+ </html>`
36
+ }
37
+
38
+ /**
39
+ * Generate the entry JS that mounts the Vue component with
40
+ * Pellicule's frame/config injection.
41
+ *
42
+ * @param {object} options
43
+ * @param {string} options.componentPath - Relative path from the temp dir to the .vue file
44
+ * @param {number} options.width
45
+ * @param {number} options.height
46
+ * @returns {string}
47
+ */
48
+ export function generateEntryJs({ componentPath, width = 1920, height = 1080 }) {
49
+ return `
50
+ import { createApp, ref, provide, h, nextTick } from 'vue'
51
+ import VideoComponent from '${componentPath}'
52
+
53
+ // Pellicule injection keys (must match composables.js)
54
+ const FRAME_KEY = Symbol.for('pellicule-frame')
55
+ const CONFIG_KEY = Symbol.for('pellicule-config')
56
+
57
+ // Get initial config from URL
58
+ const params = new URLSearchParams(window.location.search)
59
+ const fps = parseInt(params.get('fps') || '30', 10)
60
+ const durationInFrames = parseInt(params.get('duration') || '90', 10)
61
+ const width = parseInt(params.get('width') || '${width}', 10)
62
+ const height = parseInt(params.get('height') || '${height}', 10)
63
+
64
+ const config = { fps, durationInFrames, width, height }
65
+
66
+ // Frame ref - reactive, will trigger re-render when changed
67
+ const frameRef = ref(0)
68
+
69
+ // Expose setFrame function for the renderer to call
70
+ window.__PELLICULE_SET_FRAME__ = async (frame) => {
71
+ frameRef.value = frame
72
+ await nextTick() // Wait for Vue to re-render
73
+ }
74
+
75
+ try {
76
+ // Create app with frame context
77
+ const app = createApp({
78
+ setup() {
79
+ provide(FRAME_KEY, frameRef)
80
+ provide(CONFIG_KEY, config)
81
+ return () => h(VideoComponent)
82
+ }
83
+ })
84
+
85
+ app.mount('#app')
86
+ window.__PELLICULE_READY__ = true
87
+ } catch (error) {
88
+ console.error('Pellicule render error:', error)
89
+ window.__PELLICULE_READY__ = true
90
+ window.__PELLICULE_ERROR__ = error.message
91
+ }
92
+ `
93
+ }
94
+
95
+ /**
96
+ * Write the temp scaffold (.pellicule/ folder with index.html + entry.js).
97
+ *
98
+ * @param {object} options
99
+ * @param {string} options.inputPath - Absolute path to the .vue file
100
+ * @param {number} options.width
101
+ * @param {number} options.height
102
+ * @returns {Promise<{ tempDir: string, cleanup: () => Promise<void> }>}
103
+ */
104
+ export async function writeTempEntry({ inputPath, width = 1920, height = 1080 }) {
105
+ const inputDir = join(inputPath, '..')
106
+ const inputFile = basename(inputPath)
107
+ const tempDir = join(inputDir, '.pellicule')
108
+
109
+ await mkdir(tempDir, { recursive: true })
110
+
111
+ const html = generateHtml({ width, height })
112
+ const js = generateEntryJs({
113
+ componentPath: `../${inputFile}`,
114
+ width,
115
+ height
116
+ })
117
+
118
+ await writeFile(join(tempDir, 'index.html'), html)
119
+ await writeFile(join(tempDir, 'entry.js'), js)
120
+
121
+ const cleanup = async () => {
122
+ await rm(tempDir, { recursive: true, force: true })
123
+ }
124
+
125
+ return { tempDir, cleanup }
126
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Rsbuild bundler adapter for Pellicule.
3
+ *
4
+ * Handles two scenarios:
5
+ * 1. Standalone Rsbuild projects (rsbuild.config.js / rsbuild.config.ts)
6
+ * 2. Boring Stack apps (config/shipwright.js → reads the `build` key)
7
+ *
8
+ * This module is lazy-loaded. @rsbuild/core is an optional peer dependency.
9
+ */
10
+
11
+ import { resolve, dirname } from 'path'
12
+ import { fileURLToPath } from 'url'
13
+ import { createRequire } from 'module'
14
+ import { pelliculeMacroRsbuildPlugin } from '../macros/define-video-config.js'
15
+ import { writeTempEntry } from './entry.js'
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url))
18
+ const pelliculeSrc = resolve(__dirname, '..')
19
+
20
+ /**
21
+ * Load the user's Rsbuild config from a config file.
22
+ *
23
+ * For Shipwright configs, reads config/shipwright.js and
24
+ * extracts the `build` key (standard Rsbuild config).
25
+ *
26
+ * @param {string} configFile - Absolute path to the config file
27
+ * @param {'rsbuild'|'shipwright'} projectType
28
+ * @returns {Promise<object>} Rsbuild config object
29
+ */
30
+ async function loadUserConfig(configFile, projectType) {
31
+ if (projectType === 'shipwright') {
32
+ // Shipwright configs are CommonJS: module.exports.shipwright = { build: { ... } }
33
+ const require = createRequire(import.meta.url)
34
+ const mod = require(configFile)
35
+ return mod?.shipwright?.build || {}
36
+ }
37
+
38
+ // Standalone rsbuild.config.js — use Rsbuild's own config loader
39
+ const { loadConfig } = await import('@rsbuild/core')
40
+ const { content } = await loadConfig({ cwd: dirname(configFile) })
41
+ return content || {}
42
+ }
43
+
44
+ /**
45
+ * Creates an Rsbuild dev server for rendering a video component.
46
+ *
47
+ * @param {object} options
48
+ * @param {string} options.input - Absolute path to the .vue file
49
+ * @param {number} options.width - Video width
50
+ * @param {number} options.height - Video height
51
+ * @param {string|null} [options.configFile] - Path to the user's config file
52
+ * @param {'rsbuild'|'shipwright'} [options.projectType] - Which config format to read
53
+ * @returns {Promise<{ server: object, url: string, cleanup: function, tempDir: string }>}
54
+ */
55
+ export async function createVideoServer(options) {
56
+ const {
57
+ input,
58
+ width = 1920,
59
+ height = 1080,
60
+ configFile = null,
61
+ projectType = 'rsbuild'
62
+ } = options
63
+
64
+ // Resolve Rsbuild from the user's project (not from pellicule's location).
65
+ // This is necessary because pellicule may be symlinked, and ESM import()
66
+ // resolves relative to the file's physical location, not the project root.
67
+ const projectRequire = createRequire(resolve(process.cwd(), 'package.json'))
68
+
69
+ let createRsbuild, mergeRsbuildConfig
70
+ try {
71
+ const rsbuildCorePath = projectRequire.resolve('@rsbuild/core')
72
+ const rsbuildCore = await import(rsbuildCorePath)
73
+ createRsbuild = rsbuildCore.createRsbuild
74
+ mergeRsbuildConfig = rsbuildCore.mergeRsbuildConfig
75
+ } catch {
76
+ throw new Error(
77
+ 'Rsbuild is required but not installed.\n' +
78
+ 'Install it with: npm install -D @rsbuild/core @rsbuild/plugin-vue\n' +
79
+ 'Or use --bundler vite to use the Vite adapter instead.'
80
+ )
81
+ }
82
+
83
+ const inputPath = resolve(input)
84
+
85
+ // Write the shared entry scaffold (.pellicule/index.html + entry.js)
86
+ const { tempDir, cleanup: cleanupTemp } = await writeTempEntry({
87
+ inputPath,
88
+ width,
89
+ height
90
+ })
91
+
92
+ // Load @rsbuild/plugin-vue (also resolved from user's project)
93
+ let pluginVue
94
+ try {
95
+ const pluginVuePath = projectRequire.resolve('@rsbuild/plugin-vue')
96
+ const mod = await import(pluginVuePath)
97
+ pluginVue = mod.pluginVue
98
+ } catch {
99
+ throw new Error(
100
+ '@rsbuild/plugin-vue is required for Rsbuild projects.\n' +
101
+ 'Install it with: npm install -D @rsbuild/plugin-vue'
102
+ )
103
+ }
104
+
105
+ // Build resolve.alias — always alias 'pellicule' to the local source.
106
+ // Also alias 'vue' to the project's Vue to avoid duplicate Vue runtimes.
107
+ // Without this, pellicule's source files (physically located in the pellicule
108
+ // repo) would resolve 'vue' from their own node_modules, while the project's
109
+ // entry code resolves 'vue' from the project's node_modules — two different
110
+ // Vue instances means provide/inject breaks silently.
111
+ // For Shipwright projects, also add the conventional ~ and @ aliases
112
+ // that sails-hook-shipwright normally injects at runtime.
113
+ const aliases = {
114
+ 'pellicule': pelliculeSrc,
115
+ 'vue': projectRequire.resolve('vue')
116
+ }
117
+
118
+ if (projectType === 'shipwright') {
119
+ const cwd = process.cwd()
120
+ aliases['@'] = resolve(cwd, 'assets', 'js')
121
+ aliases['~'] = resolve(cwd, 'assets')
122
+ }
123
+
124
+ // Pellicule's required Rsbuild config
125
+ // logLevel: 'error' suppresses Rsbuild's "start build started..." and
126
+ // "ready built in X s" messages. Pellicule has its own progress display.
127
+ // Note: rsbuild.logger is NOT exposed on the createRsbuild() return object,
128
+ // so logger.override() doesn't work. logLevel is the config-level equivalent.
129
+ const pelliculeConfig = {
130
+ source: {
131
+ entry: {
132
+ index: resolve(tempDir, 'entry.js')
133
+ }
134
+ },
135
+ resolve: {
136
+ alias: aliases
137
+ },
138
+ html: {
139
+ template: resolve(tempDir, 'index.html')
140
+ },
141
+ plugins: [pelliculeMacroRsbuildPlugin(), pluginVue()],
142
+ server: {
143
+ strictPort: false,
144
+ printUrls: false
145
+ },
146
+ dev: {
147
+ writeToDisk: false
148
+ },
149
+ logLevel: 'error'
150
+ }
151
+
152
+ let finalConfig = pelliculeConfig
153
+
154
+ // If the user has a config file, load and merge it
155
+ if (configFile) {
156
+ const userConfig = await loadUserConfig(configFile, projectType)
157
+
158
+ if (userConfig && Object.keys(userConfig).length > 0) {
159
+ // User config is the base, Pellicule config merges on top
160
+ finalConfig = mergeRsbuildConfig(userConfig, pelliculeConfig)
161
+ }
162
+ }
163
+
164
+ const rsbuild = await createRsbuild({ rsbuildConfig: finalConfig })
165
+
166
+ const devServer = await rsbuild.createDevServer()
167
+ const { port } = await devServer.listen()
168
+
169
+ const url = `http://localhost:${port}`
170
+
171
+ const cleanup = async () => {
172
+ await devServer.close()
173
+ await cleanupTemp()
174
+ }
175
+
176
+ return { server: devServer, url, cleanup, tempDir }
177
+ }