@timber-js/app 0.1.25 → 0.1.27

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.
@@ -1 +1 @@
1
- {"version":3,"file":"server-bundle.d.ts","sourceRoot":"","sources":["../../src/plugins/server-bundle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC,wBAAgB,kBAAkB,IAAI,MAAM,EAAE,CA0G7C"}
1
+ {"version":3,"file":"server-bundle.d.ts","sourceRoot":"","sources":["../../src/plugins/server-bundle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC,wBAAgB,kBAAkB,IAAI,MAAM,EAAE,CAyH7C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Vite-native React framework for Cloudflare Workers — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -213,9 +213,15 @@ export function nitro(options: NitroAdapterOptions = {}): TimberPlatformAdapter
213
213
  // local runtime — Vite's built-in preview is the fallback.
214
214
  preview: LOCALLY_PREVIEWABLE.has(preset)
215
215
  ? async (_config: TimberConfig, buildDir: string) => {
216
- const cmd = generateNitroPreviewCommand(buildDir, preset);
217
- if (!cmd) return;
218
- await spawnNitroPreview(cmd.command, cmd.args, cmd.cwd);
216
+ // Generate a standalone preview server that uses Node's built-in
217
+ // HTTP server. The Nitro entry.ts can't be run directly because
218
+ // it imports h3 (a Nitro dependency not available at runtime).
219
+ const previewScript = generatePreviewScript(buildDir, preset);
220
+ const scriptPath = join(buildDir, 'nitro', '_preview-server.mjs');
221
+ await writeFile(scriptPath, previewScript);
222
+
223
+ const command = preset === 'bun' ? 'bun' : 'node';
224
+ await spawnNitroPreview(command, [scriptPath], join(buildDir, 'nitro'));
219
225
  }
220
226
  : undefined,
221
227
 
@@ -320,6 +326,175 @@ export default defineNitroConfig(${configJson})
320
326
  /** Presets that produce a locally-runnable server entry. */
321
327
  const LOCALLY_PREVIEWABLE = new Set<NitroPreset>(['node-server', 'bun']);
322
328
 
329
+ /**
330
+ * Generate a standalone preview server script that uses Node's built-in
331
+ * HTTP server. This bypasses Nitro entirely — the Nitro entry.ts imports
332
+ * h3 which isn't available outside a Nitro build. For local preview we
333
+ * just need to serve static files and route requests to the RSC handler.
334
+ *
335
+ * @internal Exported for testing.
336
+ */
337
+ export function generatePreviewScript(buildDir: string, preset: NitroPreset): string {
338
+ const rscEntryRelative = relative(join(buildDir, 'nitro'), join(buildDir, 'rsc', 'index.js'));
339
+ const rscEntry = rscEntryRelative.startsWith('.') ? rscEntryRelative : './' + rscEntryRelative;
340
+ const publicDir = './public';
341
+ const manifestInitPath = './_timber-manifest-init.js';
342
+ const runtimeName = PRESET_CONFIGS[preset].runtimeName;
343
+
344
+ return `// Generated by @timber-js/app — standalone preview server.
345
+ // Uses Node's built-in HTTP server to serve static assets and route
346
+ // dynamic requests through the RSC handler. No Nitro/h3 dependency.
347
+
348
+ import { createServer } from 'node:http';
349
+ import { readFile, stat } from 'node:fs/promises';
350
+ import { join, extname } from 'node:path';
351
+ import { fileURLToPath } from 'node:url';
352
+ import { existsSync } from 'node:fs';
353
+
354
+ // Set runtime before importing the handler.
355
+ process.env.TIMBER_RUNTIME = '${runtimeName}';
356
+
357
+ // Load the build manifest if it exists.
358
+ const __dirname = fileURLToPath(new URL('.', import.meta.url));
359
+ const manifestPath = join(__dirname, '${manifestInitPath}');
360
+ if (existsSync(manifestPath)) {
361
+ await import('${manifestInitPath}');
362
+ }
363
+
364
+ // Import the RSC handler (default export is the fetch-like handler).
365
+ const { default: handler, runWithEarlyHintsSender } = await import('${rscEntry}');
366
+
367
+ const MIME_TYPES = {
368
+ '.html': 'text/html',
369
+ '.js': 'application/javascript',
370
+ '.mjs': 'application/javascript',
371
+ '.css': 'text/css',
372
+ '.json': 'application/json',
373
+ '.png': 'image/png',
374
+ '.jpg': 'image/jpeg',
375
+ '.jpeg': 'image/jpeg',
376
+ '.gif': 'image/gif',
377
+ '.svg': 'image/svg+xml',
378
+ '.ico': 'image/x-icon',
379
+ '.woff': 'font/woff',
380
+ '.woff2': 'font/woff2',
381
+ '.ttf': 'font/ttf',
382
+ '.otf': 'font/otf',
383
+ '.webp': 'image/webp',
384
+ '.avif': 'image/avif',
385
+ '.webm': 'video/webm',
386
+ '.mp4': 'video/mp4',
387
+ '.txt': 'text/plain',
388
+ '.xml': 'application/xml',
389
+ '.wasm': 'application/wasm',
390
+ };
391
+
392
+ const publicDir = join(__dirname, '${publicDir}');
393
+ const port = parseInt(process.env.PORT || '3000', 10);
394
+
395
+ const server = createServer(async (req, res) => {
396
+ const url = new URL(req.url || '/', \`http://localhost:\${port}\`);
397
+
398
+ // Try serving static files from the public directory first.
399
+ const filePath = join(publicDir, url.pathname);
400
+ // Prevent path traversal.
401
+ if (filePath.startsWith(publicDir)) {
402
+ try {
403
+ const fileStat = await stat(filePath);
404
+ if (fileStat.isFile()) {
405
+ const ext = extname(filePath);
406
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
407
+ const body = await readFile(filePath);
408
+ // Hashed assets get immutable cache, others get short cache.
409
+ const cacheControl = url.pathname.startsWith('/assets/')
410
+ ? 'public, max-age=31536000, immutable'
411
+ : 'public, max-age=3600, must-revalidate';
412
+ res.writeHead(200, {
413
+ 'Content-Type': contentType,
414
+ 'Content-Length': body.length,
415
+ 'Cache-Control': cacheControl,
416
+ });
417
+ res.end(body);
418
+ return;
419
+ }
420
+ } catch {
421
+ // File not found — fall through to the RSC handler.
422
+ }
423
+ }
424
+
425
+ // Convert Node request to Web Request.
426
+ const headers = new Headers();
427
+ for (const [key, value] of Object.entries(req.headers)) {
428
+ if (value) {
429
+ if (Array.isArray(value)) {
430
+ for (const v of value) headers.append(key, v);
431
+ } else {
432
+ headers.set(key, value);
433
+ }
434
+ }
435
+ }
436
+
437
+ let body = undefined;
438
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
439
+ body = await new Promise((resolve) => {
440
+ const chunks = [];
441
+ req.on('data', (chunk) => chunks.push(chunk));
442
+ req.on('end', () => resolve(Buffer.concat(chunks)));
443
+ });
444
+ }
445
+
446
+ const webRequest = new Request(url.href, {
447
+ method: req.method,
448
+ headers,
449
+ body,
450
+ duplex: body ? 'half' : undefined,
451
+ });
452
+
453
+ try {
454
+ // Support 103 Early Hints when available.
455
+ const earlyHintsSender = (typeof res.writeEarlyHints === 'function')
456
+ ? (links) => { try { res.writeEarlyHints({ link: links }); } catch {} }
457
+ : undefined;
458
+
459
+ const webResponse = earlyHintsSender && runWithEarlyHintsSender
460
+ ? await runWithEarlyHintsSender(earlyHintsSender, () => handler(webRequest))
461
+ : await handler(webRequest);
462
+
463
+ // Write the response back to the Node response.
464
+ res.writeHead(webResponse.status, Object.fromEntries(webResponse.headers.entries()));
465
+
466
+ if (webResponse.body) {
467
+ const reader = webResponse.body.getReader();
468
+ const pump = async () => {
469
+ while (true) {
470
+ const { done, value } = await reader.read();
471
+ if (done) { res.end(); return; }
472
+ res.write(value);
473
+ }
474
+ };
475
+ await pump();
476
+ } else {
477
+ res.end();
478
+ }
479
+ } catch (err) {
480
+ console.error('[timber preview] Request error:', err);
481
+ if (!res.headersSent) {
482
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
483
+ }
484
+ res.end('Internal Server Error');
485
+ }
486
+ });
487
+
488
+ server.listen(port, () => {
489
+ console.log();
490
+ console.log(' ⚡ timber preview server running at:');
491
+ console.log();
492
+ console.log(\` ➜ http://localhost:\${port}\`);
493
+ console.log();
494
+ });
495
+ `;
496
+ }
497
+
323
498
  /** Command descriptor for Nitro preview — testable without spawning. */
324
499
  export interface NitroPreviewCommand {
325
500
  command: string;
package/src/cli.ts CHANGED
@@ -132,7 +132,7 @@ export async function runPreview(options: CommandOptions): Promise<void> {
132
132
  const adapter = config?.adapter as import('./adapters/types').TimberPlatformAdapter | undefined;
133
133
 
134
134
  if (resolvePreviewStrategy(adapter) === 'adapter') {
135
- const buildDir = join(root, '.timber', 'build');
135
+ const buildDir = join(root, 'dist');
136
136
  const timberConfig = { output: (config?.output ?? 'server') as 'server' | 'static' };
137
137
  await adapter!.preview!(timberConfig, buildDir);
138
138
  return;
@@ -95,10 +95,20 @@ export function timberServerBundle(): Plugin[] {
95
95
  // the variable assignment of the init function — so the module's React
96
96
  // imports, context creation, etc. never execute.
97
97
  //
98
- // The fix: patch the `__esmMin` runtime definition to eagerly execute
99
- // the init callback while still returning the lazy wrapper. This makes
100
- // all ESM module inits run at load time (standard ESM behavior) instead
101
- // of lazily, which is functionally correct and avoids the dropped-init bug.
98
+ // The fix: patch `__esmMin` to eagerly *attempt* each init, but fall
99
+ // back to lazy retry on failure. This handles two failure modes:
100
+ //
101
+ // 1. Forward references (e.g. Zod v4): `init_iso` calls `init_schemas`
102
+ // which hasn't been defined yet. Eager execution fails, but when the
103
+ // function is called lazily later, all dependencies are available.
104
+ //
105
+ // 2. Optional peer dep shims (e.g. @emotion/is-prop-valid for
106
+ // framer-motion): Vite generates shims that throw for missing
107
+ // optional deps. The throw is deferred to lazy execution where
108
+ // the consuming package's try/catch handles it.
109
+ //
110
+ // The key: on failure, `fn` is NOT cleared, so the next call retries.
111
+ // On success, `fn` is set to 0 so subsequent calls are no-ops.
102
112
  const esmInitFixPlugin: Plugin = {
103
113
  name: 'timber-esm-init-fix',
104
114
  applyToEnvironment(environment) {
@@ -108,11 +118,16 @@ export function timberServerBundle(): Plugin[] {
108
118
  const lazy = 'var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);';
109
119
  if (!code.includes(lazy)) return null;
110
120
 
111
- // Replace with eager-then-lazy: execute init immediately, then
112
- // return the lazy wrapper for any subsequent calls (which are
113
- // idempotent since fn is set to 0 after first execution).
114
- const eager =
115
- 'var __esmMin = (fn, res) => { var l = () => (fn && (res = fn(fn = 0)), res); l(); return l; };';
121
+ // Eager-with-retry: attempt init immediately. On success, mark done
122
+ // (fn = 0). On failure, leave fn intact so the lazy wrapper retries
123
+ // on next call by then forward dependencies are initialized.
124
+ const eager = [
125
+ 'var __esmMin = (fn, res) => {',
126
+ ' var l = () => { if (fn) { var f = fn; try { res = f(); fn = 0; } catch(e) {} } return res; };',
127
+ ' l();',
128
+ ' return l;',
129
+ '};',
130
+ ].join(' ');
116
131
 
117
132
  return { code: code.replace(lazy, eager), map: null };
118
133
  },