@b9g/shovel 0.2.0 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to Shovel will be documented in this file.
4
4
 
5
+ ## [0.2.2] - 2026-01-30
6
+
7
+ ### Bug Fixes
8
+
9
+ - **Fix asset manifest invalidation in dev mode** - Assets with new content hashes after client bundle changes no longer return 404. The root cause was build-time manifest resolution reading stale data from disk during rebuilds. ([#36](https://github.com/bikeshaving/shovel/pull/36), fixes [#35](https://github.com/bikeshaving/shovel/issues/35))
10
+
5
11
  ## [0.2.0-beta.12] - 2026-01-14
6
12
 
7
13
  ### Breaking Changes
package/README.md CHANGED
@@ -69,7 +69,9 @@ Your code uses standards. Shovel makes them work everywhere.
69
69
 
70
70
  ## Meta-Framework
71
71
 
72
- Shovel is a meta-framework: it provides and implements primitives rather than opinions. Instead of dictating how you build, it gives you portable building blocks that work everywhere.
72
+ Shovel is a meta-framework: it generates bundles and compiles your code with ESBuild.
73
+ You write code, and it runs in development and production with the exact same APIs.
74
+ Shovel takes care of single file bundle requirements, and transpiling JSX/TypeScript.
73
75
 
74
76
  ## True Portability
75
77
 
package/bin/cli.js CHANGED
@@ -75,7 +75,7 @@ program.command("develop <entrypoint>").description("Start development server wi
75
75
  DEFAULTS.WORKERS
76
76
  ).option("--platform <name>", "Runtime platform (node, cloudflare, bun)").action(async (entrypoint, options) => {
77
77
  checkPlatformReexec(options);
78
- const { developCommand } = await import("../src/_chunks/develop-UJ7PMMOM.js");
78
+ const { developCommand } = await import("../src/_chunks/develop-DNLCO6BX.js");
79
79
  await developCommand(entrypoint, options, config);
80
80
  });
81
81
  program.command("build <entrypoint>").description("Build app for production").option("--platform <name>", "Runtime platform (node, cloudflare, bun)").option(
@@ -83,7 +83,7 @@ program.command("build <entrypoint>").description("Build app for production").op
83
83
  "Run ServiceWorker lifecycle after build (install or activate, default: activate)"
84
84
  ).action(async (entrypoint, options) => {
85
85
  checkPlatformReexec(options);
86
- const { buildCommand } = await import("../src/_chunks/build-QZE6GNRD.js");
86
+ const { buildCommand } = await import("../src/_chunks/build-5DSZYRHR.js");
87
87
  await buildCommand(entrypoint, options, config);
88
88
  process.exit(0);
89
89
  });
package/bin/create.js CHANGED
@@ -7,6 +7,12 @@ import { intro, outro, text, select, confirm, spinner } from "@clack/prompts";
7
7
  import { mkdir, writeFile } from "fs/promises";
8
8
  import { join, resolve } from "path";
9
9
  import { existsSync } from "fs";
10
+ function detectPlatform() {
11
+ if (process.env.npm_config_user_agent?.includes("bun")) {
12
+ return "bun";
13
+ }
14
+ return "node";
15
+ }
10
16
  function validateProjectName(name) {
11
17
  if (!name)
12
18
  return "Project name is required";
@@ -46,54 +52,66 @@ async function main() {
46
52
  process.exit(0);
47
53
  }
48
54
  }
49
- const platform = await select({
50
- message: "Which platform are you targeting?",
55
+ const template = await select({
56
+ message: "Choose a starter template:",
51
57
  options: [
52
58
  {
53
- value: "node",
54
- label: `Node.js - Traditional server with worker threads`,
55
- hint: "Most common choice"
59
+ value: "hello-world",
60
+ label: "Hello World",
61
+ hint: "Minimal fetch handler to get started"
56
62
  },
57
63
  {
58
- value: "bun",
59
- label: `Bun - Native performance with Web Workers`
64
+ value: "api",
65
+ label: "API",
66
+ hint: "REST endpoints with JSON responses"
60
67
  },
61
68
  {
62
- value: "cloudflare",
63
- label: `Cloudflare - Edge runtime with KV/R2/D1`
69
+ value: "static-site",
70
+ label: "Static Site",
71
+ hint: "Serve files from public/ directory"
72
+ },
73
+ {
74
+ value: "full-stack",
75
+ label: "Full Stack",
76
+ hint: "HTML pages + API routes + static assets"
64
77
  }
65
78
  ]
66
79
  });
67
- if (typeof platform === "symbol") {
80
+ if (typeof template === "symbol") {
68
81
  outro("Project creation cancelled");
69
82
  process.exit(0);
70
83
  }
71
- const template = await select({
72
- message: "Choose a starter template:",
84
+ const typescript = await confirm({
85
+ message: "Use TypeScript?",
86
+ initialValue: true
87
+ });
88
+ if (typeof typescript === "symbol") {
89
+ outro("Project creation cancelled");
90
+ process.exit(0);
91
+ }
92
+ const detectedPlatform = detectPlatform();
93
+ const platform = await select({
94
+ message: "Which platform?",
95
+ initialValue: detectedPlatform,
73
96
  options: [
74
97
  {
75
- value: "basic",
76
- label: `Basic - Simple hello world with routing`
98
+ value: "node",
99
+ label: "Node.js",
100
+ hint: detectedPlatform === "node" ? "detected" : void 0
77
101
  },
78
102
  {
79
- value: "api",
80
- label: `API - REST endpoints with JSON responses`
103
+ value: "bun",
104
+ label: "Bun",
105
+ hint: detectedPlatform === "bun" ? "detected" : void 0
81
106
  },
82
107
  {
83
- value: "echo",
84
- label: `Echo - HTTP request echo service (like httpbin)`
108
+ value: "cloudflare",
109
+ label: "Cloudflare Workers",
110
+ hint: "Edge runtime"
85
111
  }
86
112
  ]
87
113
  });
88
- if (typeof template === "symbol") {
89
- outro("Project creation cancelled");
90
- process.exit(0);
91
- }
92
- const typescript = await confirm({
93
- message: "Use TypeScript?",
94
- initialValue: false
95
- });
96
- if (typeof typescript === "symbol") {
114
+ if (typeof platform === "symbol") {
97
115
  outro("Project creation cancelled");
98
116
  process.exit(0);
99
117
  }
@@ -112,11 +130,11 @@ async function main() {
112
130
  outro("Your Shovel project is ready!");
113
131
  console.info("");
114
132
  console.info("Next steps:");
115
- console.info(` $ cd ${projectName}`);
116
- console.info(` $ npm install`);
117
- console.info(` $ npm run develop`);
133
+ console.info(` cd ${projectName}`);
134
+ console.info(` npm install`);
135
+ console.info(` npm run dev`);
118
136
  console.info("");
119
- console.info(`Your app will be available at: http://localhost:3000`);
137
+ console.info("Your app will be available at: http://localhost:3000");
120
138
  console.info("");
121
139
  } catch (error) {
122
140
  s.stop("Failed to create project");
@@ -127,25 +145,28 @@ async function main() {
127
145
  async function createProject(config, projectPath) {
128
146
  await mkdir(projectPath, { recursive: true });
129
147
  await mkdir(join(projectPath, "src"), { recursive: true });
148
+ if (config.template === "static-site" || config.template === "full-stack") {
149
+ await mkdir(join(projectPath, "public"), { recursive: true });
150
+ }
151
+ const ext = config.typescript ? "ts" : "js";
130
152
  const packageJson = {
131
153
  name: config.name,
132
154
  private: true,
133
- version: "1.0.0",
134
- description: `Shovel ${config.template} app for ${config.platform}`,
155
+ version: "0.0.1",
135
156
  type: "module",
136
157
  scripts: {
137
- develop: `shovel develop src/app.${config.typescript ? "ts" : "js"} --platform ${config.platform}`,
138
- build: `shovel build src/app.${config.typescript ? "ts" : "js"} --platform ${config.platform}`,
139
- start: "node dist/server/index.js"
158
+ dev: `shovel develop src/app.${ext} --platform ${config.platform}`,
159
+ build: `shovel build src/app.${ext} --platform ${config.platform}`,
160
+ start: "node dist/server/supervisor.js"
140
161
  },
141
162
  dependencies: {
142
- "@b9g/router": "^0.1.0",
143
- "@b9g/platform": "^0.1.0",
144
- [`@b9g/platform-${config.platform}`]: "^0.1.0",
145
- "@b9g/shovel": "^0.1.0"
163
+ "@b9g/router": "^0.2.0",
164
+ "@b9g/shovel": "^0.2.0",
165
+ "@b9g/filesystem": "^0.1.8",
166
+ "@b9g/cache": "^0.2.0"
146
167
  },
147
168
  devDependencies: config.typescript ? {
148
- "@types/node": "^20.0.0",
169
+ "@types/node": "^18.0.0",
149
170
  typescript: "^5.0.0"
150
171
  } : {}
151
172
  };
@@ -154,8 +175,7 @@ async function createProject(config, projectPath) {
154
175
  JSON.stringify(packageJson, null, 2)
155
176
  );
156
177
  const appFile = generateAppFile(config);
157
- const appExt = config.typescript ? "ts" : "js";
158
- await writeFile(join(projectPath, `src/app.${appExt}`), appFile);
178
+ await writeFile(join(projectPath, `src/app.${ext}`), appFile);
159
179
  if (config.typescript) {
160
180
  const tsConfig = {
161
181
  compilerOptions: {
@@ -177,6 +197,9 @@ async function createProject(config, projectPath) {
177
197
  JSON.stringify(tsConfig, null, 2)
178
198
  );
179
199
  }
200
+ if (config.template === "static-site" || config.template === "full-stack") {
201
+ await createStaticFiles(config, projectPath);
202
+ }
180
203
  const readme = generateReadme(config);
181
204
  await writeFile(join(projectPath, "README.md"), readme);
182
205
  const gitignore = `node_modules/
@@ -188,447 +211,314 @@ dist/
188
211
  `;
189
212
  await writeFile(join(projectPath, ".gitignore"), gitignore);
190
213
  }
191
- function generateAppFile(config) {
192
- const helperImports = config.template === "echo" && config.typescript ? `
193
- // Helper functions for echo service
194
- function getRequestInfo(request: Request) {
195
- return {
196
- method: request.method,
197
- url: request.url,
198
- headers: Object.fromEntries(request.headers.entries()),
199
- };
214
+ async function createStaticFiles(config, projectPath) {
215
+ const indexHtml = `<!DOCTYPE html>
216
+ <html lang="en">
217
+ <head>
218
+ <meta charset="UTF-8">
219
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
220
+ <title>${config.name}</title>
221
+ <link rel="stylesheet" href="/styles.css">
222
+ </head>
223
+ <body>
224
+ <main>
225
+ <h1>Welcome to ${config.name}</h1>
226
+ <p>Edit <code>public/index.html</code> to get started.</p>
227
+ ${config.template === "full-stack" ? '<p>API endpoint: <a href="/api/hello">/api/hello</a></p>' : ""}
228
+ </main>
229
+ </body>
230
+ </html>
231
+ `;
232
+ await writeFile(join(projectPath, "public/index.html"), indexHtml);
233
+ const stylesCss = `* {
234
+ box-sizing: border-box;
235
+ margin: 0;
236
+ padding: 0;
200
237
  }
201
238
 
202
- async function parseBody(request: Request) {
203
- const contentType = request.headers.get('content-type') || '';
204
-
205
- if (contentType.includes('application/json')) {
206
- try {
207
- return await request.json();
208
- } catch (err) {
209
- // Only ignore JSON parse errors, rethrow others
210
- if (
211
- !(err instanceof SyntaxError) ||
212
- !/^(Unexpected token|Expected|JSON)/i.test(String(err.message))
213
- ) {
214
- throw err;
215
- }
216
- return null;
217
- }
218
- }
219
-
220
- if (contentType.includes('application/x-www-form-urlencoded')) {
221
- try {
222
- const formData = await request.formData();
223
- return Object.fromEntries(formData.entries());
224
- } catch (err) {
225
- // Only ignore form data parse errors, rethrow others
226
- if (
227
- !(err instanceof TypeError) ||
228
- !String(err.message).includes('FormData')
229
- ) {
230
- throw err;
231
- }
232
- return null;
233
- }
234
- }
235
-
236
- try {
237
- const text = await request.text();
238
- return text || null;
239
- } catch (err) {
240
- // Only ignore body already consumed errors, rethrow others
241
- if (
242
- !(err instanceof TypeError) ||
243
- !String(err.message).includes('body')
244
- ) {
245
- throw err;
246
- }
247
- return null;
248
- }
249
- }
250
- ` : config.template === "echo" ? `
251
- // Helper functions for echo service
252
- function getRequestInfo(request) {
253
- return {
254
- method: request.method,
255
- url: request.url,
256
- headers: Object.fromEntries(request.headers.entries()),
257
- };
239
+ body {
240
+ font-family: system-ui, -apple-system, sans-serif;
241
+ line-height: 1.6;
242
+ color: #333;
243
+ background: #fafafa;
258
244
  }
259
245
 
260
- async function parseBody(request) {
261
- const contentType = request.headers.get('content-type') || '';
246
+ main {
247
+ max-width: 640px;
248
+ margin: 4rem auto;
249
+ padding: 2rem;
250
+ }
262
251
 
263
- if (contentType.includes('application/json')) {
264
- try {
265
- return await request.json();
266
- } catch (err) {
267
- // Only ignore JSON parse errors, rethrow others
268
- if (
269
- !(err instanceof SyntaxError) ||
270
- !/^(Unexpected token|Expected|JSON)/i.test(String(err.message))
271
- ) {
272
- throw err;
273
- }
274
- return null;
275
- }
276
- }
252
+ h1 {
253
+ color: #2563eb;
254
+ margin-bottom: 1rem;
255
+ }
277
256
 
278
- if (contentType.includes('application/x-www-form-urlencoded')) {
279
- try {
280
- const formData = await request.formData();
281
- return Object.fromEntries(formData.entries());
282
- } catch (err) {
283
- // Only ignore form data parse errors, rethrow others
284
- if (
285
- !(err instanceof TypeError) ||
286
- !String(err.message).includes('FormData')
287
- ) {
288
- throw err;
289
- }
290
- return null;
291
- }
292
- }
257
+ code {
258
+ background: #e5e7eb;
259
+ padding: 0.2rem 0.4rem;
260
+ border-radius: 4px;
261
+ font-size: 0.9em;
262
+ }
293
263
 
294
- try {
295
- const text = await request.text();
296
- return text || null;
297
- } catch (err) {
298
- // Only ignore body already consumed errors, rethrow others
299
- if (
300
- !(err instanceof TypeError) ||
301
- !String(err.message).includes('body')
302
- ) {
303
- throw err;
304
- }
305
- return null;
264
+ a {
265
+ color: #2563eb;
266
+ }
267
+ `;
268
+ await writeFile(join(projectPath, "public/styles.css"), stylesCss);
269
+ }
270
+ function generateAppFile(config) {
271
+ switch (config.template) {
272
+ case "hello-world":
273
+ return generateHelloWorld(config);
274
+ case "api":
275
+ return generateApi(config);
276
+ case "static-site":
277
+ return generateStaticSite(config);
278
+ case "full-stack":
279
+ return generateFullStack(config);
280
+ default:
281
+ return generateHelloWorld(config);
306
282
  }
307
283
  }
308
- ` : "";
309
- return `${helperImports}import { Router } from "@b9g/router";
284
+ function generateHelloWorld(config) {
285
+ return `// ${config.name} - Hello World
286
+ self.addEventListener("fetch", (event) => {
287
+ event.respondWith(
288
+ new Response("Hello from Shovel!", {
289
+ headers: { "Content-Type": "text/plain" },
290
+ })
291
+ );
292
+ });
293
+ `;
294
+ }
295
+ function generateApi(config) {
296
+ return `import { Router } from "@b9g/router";
310
297
 
311
298
  const router = new Router();
312
299
 
313
- ${generateRoutes(config)}
314
-
315
- // ServiceWorker lifecycle events
316
- self.addEventListener("install", (event) => {
317
- console.info("[${config.name}] ServiceWorker installed");
318
- });
300
+ // In-memory data store
301
+ const users = [
302
+ { id: 1, name: "Alice", email: "alice@example.com" },
303
+ { id: 2, name: "Bob", email: "bob@example.com" },
304
+ ];
319
305
 
320
- self.addEventListener("activate", (event) => {
321
- console.info("[${config.name}] ServiceWorker activated");
306
+ // List all users
307
+ router.route("/api/users").get(() => {
308
+ return Response.json({ users });
322
309
  });
323
310
 
324
- // Handle HTTP requests
325
- self.addEventListener("fetch", (event) => {
326
- try {
327
- const responsePromise = router.handle(event.request);
328
- event.respondWith(responsePromise);
329
- } catch (error) {
330
- console.error("[${config.name}] Error handling request:", error);
331
- event.respondWith(
332
- new Response(
333
- JSON.stringify({
334
- error: "Internal server error",
335
- message: error.message,
336
- }),
337
- {
338
- status: 500,
339
- headers: { "Content-Type": "application/json" },
340
- }
341
- )
342
- );
311
+ // Get user by ID
312
+ router.route("/api/users/:id").get((req, ctx) => {
313
+ const user = users.find((u) => u.id === Number(ctx.params.id));
314
+ if (!user) {
315
+ return Response.json({ error: "User not found" }, { status: 404 });
343
316
  }
317
+ return Response.json({ user });
344
318
  });
345
- `;
346
- }
347
- function generateRoutes(config) {
348
- switch (config.template) {
349
- case "basic":
350
- return `// Basic routes
351
- router.route("/").get(async (request, context) => {
352
- const html = \`
353
- <!DOCTYPE html>
354
- <html>
355
- <head>
356
- <title>Welcome to Shovel!</title>
357
- <meta charset="utf-8">
358
- <meta name="viewport" content="width=device-width, initial-scale=1">
359
- <style>
360
- body { font-family: system-ui; max-width: 600px; margin: 2rem auto; padding: 2rem; }
361
- h1 { color: #2563eb; }
362
- .info { background: #f1f5f9; padding: 1rem; border-radius: 8px; margin: 1rem 0; }
363
- code { background: #e2e8f0; padding: 0.2rem 0.4rem; border-radius: 4px; }
364
- </style>
365
- </head>
366
- <body>
367
- <h1>\u{1F680} Welcome to Shovel!</h1>
368
- <p>Your ${config.template} app is running on the <strong>${config.platform}</strong> platform.</p>
369
-
370
- <div class="info">
371
- <strong>Try these endpoints:</strong>
372
- <ul>
373
- <li><a href="/api/hello">GET /api/hello</a> - Simple API endpoint</li>
374
- <li><a href="/api/time">GET /api/time</a> - Current timestamp</li>
375
- </ul>
376
- </div>
377
-
378
- <p>Edit <code>src/app.${config.typescript ? "ts" : "js"}</code> to customize your app!</p>
379
- </body>
380
- </html>
381
- \`;
382
-
383
- return new Response(html, {
384
- headers: { "Content-Type": "text/html" }
385
- });
319
+
320
+ // Create user
321
+ router.route("/api/users").post(async (req) => {
322
+ const body = await req.json();
323
+ const user = {
324
+ id: users.length + 1,
325
+ name: body.name,
326
+ email: body.email,
327
+ };
328
+ users.push(user);
329
+ return Response.json({ user }, { status: 201 });
386
330
  });
387
331
 
388
- router.route("/api/hello").get(async (request, context) => {
389
- return new Response(JSON.stringify({
390
- message: "Hello from Shovel! \u{1F680}",
391
- platform: "${config.platform}",
392
- timestamp: new Date().toISOString()
393
- }), {
394
- headers: { "Content-Type": "application/json" }
395
- });
332
+ // Health check
333
+ router.route("/health").get(() => {
334
+ return Response.json({ status: "ok", timestamp: new Date().toISOString() });
396
335
  });
397
336
 
398
- router.route("/api/time").get(async (request, context) => {
399
- return new Response(JSON.stringify({
400
- time: new Date().toISOString(),
401
- unix: Date.now(),
402
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
403
- }), {
404
- headers: { "Content-Type": "application/json" }
405
- });
406
- });`;
407
- case "api":
408
- return `// API routes
409
- router.route("/").get(async (request, context) => {
410
- return new Response(JSON.stringify({
337
+ // Root - API info
338
+ router.route("/").get(() => {
339
+ return Response.json({
411
340
  name: "${config.name}",
412
- platform: "${config.platform}",
413
341
  endpoints: [
414
- { method: "GET", path: "/api/users", description: "Get all users" },
415
- { method: "POST", path: "/api/users", description: "Create a user" },
416
- { method: "GET", path: "/api/users/:id", description: "Get user by ID" },
417
- { method: "GET", path: "/health", description: "Health check" }
418
- ]
419
- }), {
420
- headers: { "Content-Type": "application/json" }
342
+ "GET /api/users",
343
+ "GET /api/users/:id",
344
+ "POST /api/users",
345
+ "GET /health",
346
+ ],
421
347
  });
422
348
  });
423
349
 
424
- // Mock users data
425
- const users = [
426
- { id: 1, name: "Alice Johnson", email: "alice@example.com", active: true },
427
- { id: 2, name: "Bob Smith", email: "bob@example.com", active: true },
428
- { id: 3, name: "Carol Davis", email: "carol@example.com", active: false }
429
- ];
430
-
431
- router.route("/api/users").get(async (request, context) => {
432
- const url = new URL(request.url);
433
- const active = url.searchParams.get('active');
434
-
435
- let filteredUsers = users;
436
- if (active !== null) {
437
- filteredUsers = users.filter(user => user.active === (active === 'true'));
438
- }
439
-
440
- return new Response(JSON.stringify({
441
- users: filteredUsers,
442
- total: filteredUsers.length
443
- }), {
444
- headers: { "Content-Type": "application/json" }
445
- });
350
+ self.addEventListener("fetch", (event) => {
351
+ event.respondWith(router.handle(event.request));
446
352
  });
353
+ `;
354
+ }
355
+ function generateStaticSite(config) {
356
+ return `// ${config.name} - Static Site
357
+ // Serves files from the public/ directory
447
358
 
448
- router.route("/api/users").post(async (request, context) => {
449
- const userData = await request.json();
450
- const newUser = {
451
- id: Math.max(...users.map(u => u.id)) + 1,
452
- name: userData.name || "Unknown User",
453
- email: userData.email || \`user\${Date.now()}@example.com\`,
454
- active: userData.active !== false
455
- };
359
+ self.addEventListener("fetch", (event) => {
360
+ event.respondWith(handleRequest(event.request));
361
+ });
456
362
 
457
- users.push(newUser);
363
+ async function handleRequest(request${config.typescript ? ": Request" : ""})${config.typescript ? ": Promise<Response>" : ""} {
364
+ const url = new URL(request.url);
365
+ let path = url.pathname;
458
366
 
459
- return new Response(JSON.stringify({
460
- success: true,
461
- user: newUser
462
- }), {
463
- status: 201,
464
- headers: { "Content-Type": "application/json" }
465
- });
466
- });
367
+ // Default to index.html for root
368
+ if (path === "/") {
369
+ path = "/index.html";
370
+ }
467
371
 
468
- router.route("/api/users/:id").get(async (request, context) => {
469
- const id = parseInt(context.params.id);
470
- const user = users.find(u => u.id === id);
372
+ // Try to serve from public directory
373
+ try {
374
+ const publicDir = await directories.open("public");
375
+ const file = await publicDir.getFileHandle(path.slice(1)); // Remove leading /
376
+ const blob = await file.getFile();
471
377
 
472
- if (!user) {
473
- return new Response(JSON.stringify({
474
- error: "User not found"
475
- }), {
476
- status: 404,
477
- headers: { "Content-Type": "application/json" }
378
+ return new Response(blob, {
379
+ headers: {
380
+ "Content-Type": getContentType(path),
381
+ },
478
382
  });
383
+ } catch {
384
+ // File not found - return 404
385
+ return new Response("Not Found", { status: 404 });
479
386
  }
387
+ }
480
388
 
481
- return new Response(JSON.stringify({ user }), {
482
- headers: { "Content-Type": "application/json" }
483
- });
484
- });
389
+ function getContentType(path${config.typescript ? ": string" : ""})${config.typescript ? ": string" : ""} {
390
+ const ext = path.split(".").pop()?.toLowerCase();
391
+ const types${config.typescript ? ": Record<string, string>" : ""} = {
392
+ html: "text/html",
393
+ css: "text/css",
394
+ js: "text/javascript",
395
+ json: "application/json",
396
+ png: "image/png",
397
+ jpg: "image/jpeg",
398
+ jpeg: "image/jpeg",
399
+ gif: "image/gif",
400
+ svg: "image/svg+xml",
401
+ ico: "image/x-icon",
402
+ };
403
+ return types[ext || ""] || "application/octet-stream";
404
+ }
405
+ `;
406
+ }
407
+ function generateFullStack(config) {
408
+ return `import { Router } from "@b9g/router";
409
+
410
+ const router = new Router();
485
411
 
486
- router.route("/health").get(async (request, context) => {
487
- return new Response(JSON.stringify({
488
- status: "ok",
489
- platform: "${config.platform}",
412
+ // API routes
413
+ router.route("/api/hello").get(() => {
414
+ return Response.json({
415
+ message: "Hello from the API!",
490
416
  timestamp: new Date().toISOString(),
491
- uptime: process.uptime()
492
- }), {
493
- headers: { "Content-Type": "application/json" }
494
- });
495
- });`;
496
- case "echo":
497
- return `// Echo service routes (like httpbin)
498
- router.route("/").get(async (request, context) => {
499
- const html = \`
500
- <!DOCTYPE html>
501
- <html>
502
- <head>
503
- <title>HTTP Echo Service</title>
504
- <meta charset="utf-8">
505
- <meta name="viewport" content="width=device-width, initial-scale=1">
506
- <style>
507
- body { font-family: monospace; max-width: 800px; margin: 2rem auto; padding: 2rem; }
508
- h1 { color: #059669; }
509
- .endpoint { background: #f0fdf4; padding: 1rem; margin: 1rem 0; border-radius: 8px; }
510
- code { background: #dcfce7; padding: 0.2rem 0.4rem; border-radius: 4px; }
511
- </style>
512
- </head>
513
- <body>
514
- <h1>\u{1F504} HTTP Echo Service</h1>
515
- <p>A simple HTTP request/response inspection service.</p>
516
-
517
- <div class="endpoint">
518
- <strong>POST /echo</strong><br>
519
- Echo back request details including headers, body, and metadata.
520
- </div>
521
-
522
- <div class="endpoint">
523
- <strong>GET /ip</strong><br>
524
- Get your IP address.
525
- </div>
526
-
527
- <div class="endpoint">
528
- <strong>GET /headers</strong><br>
529
- Get your request headers.
530
- </div>
531
-
532
- <div class="endpoint">
533
- <strong>GET /user-agent</strong><br>
534
- Get your user agent string.
535
- </div>
536
-
537
- <p>Try: <code>curl -X POST https://your-app.com/echo -d '{"test": "data"}'</code></p>
538
- </body>
539
- </html>
540
- \`;
541
-
542
- return new Response(html, {
543
- headers: { "Content-Type": "text/html" }
544
417
  });
545
418
  });
546
419
 
547
- router.route("/echo").all(async (request, context) => {
548
- const info = getRequestInfo(request);
549
- const body = await parseBody(request);
420
+ router.route("/api/echo").post(async (req) => {
421
+ const body = await req.json();
422
+ return Response.json({ echo: body });
423
+ });
550
424
 
551
- const response = {
552
- ...info,
553
- body,
554
- contentType: request.headers.get("content-type") || null,
555
- timestamp: new Date().toISOString()
556
- };
425
+ // Serve static files from public/ for all other routes
426
+ router.route("/*").get(async (req, ctx) => {
427
+ const url = new URL(req.url);
428
+ let path = url.pathname;
557
429
 
558
- return new Response(JSON.stringify(response, null, 2), {
559
- headers: { "Content-Type": "application/json" }
560
- });
561
- });
430
+ // Default to index.html for root
431
+ if (path === "/") {
432
+ path = "/index.html";
433
+ }
562
434
 
563
- router.route("/ip").get(async (request, context) => {
564
- const ip = request.headers.get("x-forwarded-for") ||
565
- request.headers.get("x-real-ip") ||
566
- "127.0.0.1";
435
+ try {
436
+ const publicDir = await directories.open("public");
437
+ const file = await publicDir.getFileHandle(path.slice(1));
438
+ const blob = await file.getFile();
567
439
 
568
- return new Response(JSON.stringify({ ip }), {
569
- headers: { "Content-Type": "application/json" }
570
- });
440
+ return new Response(blob, {
441
+ headers: {
442
+ "Content-Type": getContentType(path),
443
+ },
444
+ });
445
+ } catch {
446
+ // Try index.html for SPA routing
447
+ try {
448
+ const publicDir = await directories.open("public");
449
+ const file = await publicDir.getFileHandle("index.html");
450
+ const blob = await file.getFile();
451
+ return new Response(blob, {
452
+ headers: { "Content-Type": "text/html" },
453
+ });
454
+ } catch {
455
+ return new Response("Not Found", { status: 404 });
456
+ }
457
+ }
571
458
  });
572
459
 
573
- router.route("/headers").get(async (request, context) => {
574
- return new Response(JSON.stringify({
575
- headers: Object.fromEntries(request.headers.entries())
576
- }, null, 2), {
577
- headers: { "Content-Type": "application/json" }
578
- });
579
- });
460
+ function getContentType(path${config.typescript ? ": string" : ""})${config.typescript ? ": string" : ""} {
461
+ const ext = path.split(".").pop()?.toLowerCase();
462
+ const types${config.typescript ? ": Record<string, string>" : ""} = {
463
+ html: "text/html",
464
+ css: "text/css",
465
+ js: "text/javascript",
466
+ json: "application/json",
467
+ png: "image/png",
468
+ jpg: "image/jpeg",
469
+ jpeg: "image/jpeg",
470
+ gif: "image/gif",
471
+ svg: "image/svg+xml",
472
+ ico: "image/x-icon",
473
+ };
474
+ return types[ext || ""] || "application/octet-stream";
475
+ }
580
476
 
581
- router.route("/user-agent").get(async (request, context) => {
582
- return new Response(JSON.stringify({
583
- userAgent: request.headers.get("user-agent") || "Unknown"
584
- }), {
585
- headers: { "Content-Type": "application/json" }
586
- });
587
- });`;
588
- default:
589
- return "// Routes will be added here";
590
- }
477
+ self.addEventListener("fetch", (event) => {
478
+ event.respondWith(router.handle(event.request));
479
+ });
480
+ `;
591
481
  }
592
482
  function generateReadme(config) {
483
+ const templateDescriptions = {
484
+ "hello-world": "A minimal Shovel application",
485
+ api: "A REST API with JSON endpoints",
486
+ "static-site": "A static file server",
487
+ "full-stack": "A full-stack app with API routes and static files"
488
+ };
593
489
  return `# ${config.name}
594
490
 
595
- A Shovel ${config.template} application for the ${config.platform} platform.
491
+ ${templateDescriptions[config.template]}, built with [Shovel](https://github.com/bikeshaving/shovel).
596
492
 
597
- ## \u{1F680} Getting Started
493
+ ## Getting Started
598
494
 
599
495
  \`\`\`bash
600
496
  npm install
601
- npm run develop
497
+ npm run dev
602
498
  \`\`\`
603
499
 
604
- Your app will be available at: **http://localhost:3000**
605
-
606
- ## \u{1F4C1} Project Structure
500
+ Open http://localhost:3000
607
501
 
608
- - \`src/app.${config.typescript ? "ts" : "js"}\` - Main ServiceWorker application
609
- - \`package.json\` - Dependencies and scripts${config.typescript ? "\n- `tsconfig.json` - TypeScript configuration" : ""}
502
+ ## Scripts
610
503
 
611
- ## \u{1F6E0}\uFE0F Available Scripts
612
-
613
- - \`npm run develop\` - Start development server with hot reload
504
+ - \`npm run dev\` - Start development server
614
505
  - \`npm run build\` - Build for production
615
- - \`npm run start\` - Start production server
616
-
617
- ## \u2728 Features
618
-
619
- - \u2705 **ServiceWorker APIs** - Standard web APIs (\`self.addEventListener\`, etc.)
620
- - \u2705 **${config.platform} Runtime** - Optimized for ${config.platform}
621
- - \u2705 **${config.typescript ? "TypeScript" : "JavaScript"}** - ${config.typescript ? "Full type safety" : "Modern JavaScript"} with ESM modules
622
- - \u2705 **${config.template} Template** - Ready-to-use starter with routing
506
+ - \`npm start\` - Run production build
623
507
 
624
- ## \u{1F4DA} Learn More
508
+ ## Project Structure
625
509
 
626
- - [Shovel Documentation](https://github.com/b9g/shovel)
627
- - [ServiceWorker API](https://developer.mozilla.org/docs/Web/API/ServiceWorker)
510
+ \`\`\`
511
+ ${config.name}/
512
+ \u251C\u2500\u2500 src/
513
+ \u2502 \u2514\u2500\u2500 app.${config.typescript ? "ts" : "js"} # Application entry point
514
+ ${config.template === "static-site" || config.template === "full-stack" ? "\u251C\u2500\u2500 public/ # Static files\n\u2502 \u251C\u2500\u2500 index.html\n\u2502 \u2514\u2500\u2500 styles.css\n" : ""}\u251C\u2500\u2500 package.json
515
+ ${config.typescript ? "\u251C\u2500\u2500 tsconfig.json\n" : ""}\u2514\u2500\u2500 README.md
516
+ \`\`\`
628
517
 
629
- ---
518
+ ## Learn More
630
519
 
631
- Built with \u{1F680} [Shovel](https://github.com/b9g/shovel) - The ServiceWorker framework that runs everywhere.
520
+ - [Shovel Documentation](https://github.com/bikeshaving/shovel)
521
+ - [ServiceWorker API](https://developer.mozilla.org/docs/Web/API/Service_Worker_API)
632
522
  `;
633
523
  }
634
524
  process.on("SIGINT", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@b9g/shovel",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "ServiceWorker-first universal deployment platform. Write ServiceWorker apps once, deploy anywhere (Node/Bun/Cloudflare). Registry-based multi-app orchestration.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ServerBundler,
3
3
  loadPlatformModule
4
- } from "./chunk-LVKYX67O.js";
4
+ } from "./chunk-ZIKUWTXK.js";
5
5
  import {
6
6
  findProjectRoot,
7
7
  findWorkspaceRoot
@@ -475,6 +475,12 @@ function createAssetsManifestPlugin(projectRoot, outDir = "dist", sharedManifest
475
475
  namespace: "shovel-assets"
476
476
  }));
477
477
  build2.onLoad({ filter: /.*/, namespace: "shovel-assets" }, () => {
478
+ if (sharedManifest) {
479
+ return {
480
+ contents: `export default ${MANIFEST_PLACEHOLDER};`,
481
+ loader: "js"
482
+ };
483
+ }
478
484
  if (existsSync2(manifestPath)) {
479
485
  try {
480
486
  const manifest = JSON.parse(readFileSync2(manifestPath, "utf8"));
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ServerBundler,
3
3
  loadPlatformModule
4
- } from "./chunk-LVKYX67O.js";
4
+ } from "./chunk-ZIKUWTXK.js";
5
5
  import {
6
6
  DEFAULTS
7
7
  } from "./chunk-NZVIBZYG.js";