alchemy 0.69.0 → 0.69.1

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.
Files changed (114) hide show
  1. package/bin/alchemy.js +14 -33
  2. package/bin/commands/init.ts +41 -55
  3. package/lib/cloudflare/bucket-object.d.ts +5 -0
  4. package/lib/cloudflare/bucket-object.d.ts.map +1 -1
  5. package/lib/cloudflare/bucket-object.js +1 -1
  6. package/lib/cloudflare/bucket-object.js.map +1 -1
  7. package/lib/cloudflare/bucket.d.ts +6 -3
  8. package/lib/cloudflare/bucket.d.ts.map +1 -1
  9. package/lib/cloudflare/bucket.js +60 -8
  10. package/lib/cloudflare/bucket.js.map +1 -1
  11. package/lib/cloudflare/container.js +1 -1
  12. package/lib/cloudflare/container.js.map +1 -1
  13. package/lib/cloudflare/miniflare/miniflare-worker-proxy.d.ts.map +1 -1
  14. package/lib/cloudflare/miniflare/miniflare-worker-proxy.js +44 -12
  15. package/lib/cloudflare/miniflare/miniflare-worker-proxy.js.map +1 -1
  16. package/lib/cloudflare/tanstack-start/plugin.d.ts +2 -15
  17. package/lib/cloudflare/tanstack-start/plugin.d.ts.map +1 -1
  18. package/lib/cloudflare/tanstack-start/plugin.js +5 -25
  19. package/lib/cloudflare/tanstack-start/plugin.js.map +1 -1
  20. package/lib/cloudflare/tanstack-start/tanstack-start.d.ts.map +1 -1
  21. package/lib/cloudflare/tanstack-start/tanstack-start.js +15 -4
  22. package/lib/cloudflare/tanstack-start/tanstack-start.js.map +1 -1
  23. package/lib/cloudflare/website.d.ts.map +1 -1
  24. package/lib/cloudflare/website.js +1 -0
  25. package/lib/cloudflare/website.js.map +1 -1
  26. package/lib/cloudflare/worker-metadata.d.ts +4 -1
  27. package/lib/cloudflare/worker-metadata.d.ts.map +1 -1
  28. package/lib/cloudflare/worker-metadata.js +2 -0
  29. package/lib/cloudflare/worker-metadata.js.map +1 -1
  30. package/lib/cloudflare/worker.d.ts +6 -0
  31. package/lib/cloudflare/worker.d.ts.map +1 -1
  32. package/lib/cloudflare/worker.js +1 -0
  33. package/lib/cloudflare/worker.js.map +1 -1
  34. package/lib/cloudflare/wrangler.json.d.ts +7 -1
  35. package/lib/cloudflare/wrangler.json.d.ts.map +1 -1
  36. package/lib/cloudflare/wrangler.json.js +9 -0
  37. package/lib/cloudflare/wrangler.json.js.map +1 -1
  38. package/lib/docker/api.d.ts +22 -1
  39. package/lib/docker/api.d.ts.map +1 -1
  40. package/lib/docker/api.js +64 -2
  41. package/lib/docker/api.js.map +1 -1
  42. package/lib/docker/container.d.ts +88 -0
  43. package/lib/docker/container.d.ts.map +1 -1
  44. package/lib/docker/container.js +36 -0
  45. package/lib/docker/container.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/cloudflare/bucket-object.ts +11 -1
  48. package/src/cloudflare/bucket.ts +72 -6
  49. package/src/cloudflare/container.ts +2 -2
  50. package/src/cloudflare/miniflare/miniflare-worker-proxy.ts +57 -13
  51. package/src/cloudflare/tanstack-start/plugin.ts +7 -28
  52. package/src/cloudflare/tanstack-start/tanstack-start.ts +15 -4
  53. package/src/cloudflare/website.ts +1 -0
  54. package/src/cloudflare/worker-metadata.ts +6 -1
  55. package/src/cloudflare/worker.ts +7 -0
  56. package/src/cloudflare/wrangler.json.ts +15 -1
  57. package/src/docker/api.ts +86 -2
  58. package/src/docker/container.ts +97 -0
  59. package/templates/tanstack-start/.cta.json +13 -0
  60. package/templates/tanstack-start/README.md +267 -49
  61. package/templates/tanstack-start/_gitignore +9 -18
  62. package/templates/tanstack-start/alchemy.run.ts +2 -2
  63. package/templates/tanstack-start/package.json +24 -19
  64. package/templates/tanstack-start/public/favicon.ico +0 -0
  65. package/templates/tanstack-start/public/logo192.png +0 -0
  66. package/templates/tanstack-start/public/logo512.png +0 -0
  67. package/templates/tanstack-start/public/manifest.json +25 -0
  68. package/templates/tanstack-start/public/robots.txt +3 -0
  69. package/templates/tanstack-start/src/components/Header.tsx +21 -0
  70. package/templates/tanstack-start/src/logo.svg +12 -0
  71. package/templates/tanstack-start/src/router.tsx +7 -16
  72. package/templates/tanstack-start/src/routes/__root.tsx +13 -114
  73. package/templates/tanstack-start/src/routes/api.demo-names.ts +15 -0
  74. package/templates/tanstack-start/src/routes/demo.start.api-request.tsx +44 -0
  75. package/templates/tanstack-start/src/routes/demo.start.server-funcs.tsx +97 -0
  76. package/templates/tanstack-start/src/routes/index.tsx +31 -4
  77. package/templates/tanstack-start/src/styles.css +15 -0
  78. package/templates/tanstack-start/tsconfig.json +19 -22
  79. package/templates/tanstack-start/types/env.d.ts +2 -2
  80. package/templates/tanstack-start/vite.config.ts +16 -25
  81. package/workers/tunnel-proxy.js +1 -1
  82. package/templates/tanstack-start/_prettierignore +0 -4
  83. package/templates/tanstack-start/public/android-chrome-192x192.png +0 -0
  84. package/templates/tanstack-start/public/android-chrome-512x512.png +0 -0
  85. package/templates/tanstack-start/public/apple-touch-icon.png +0 -0
  86. package/templates/tanstack-start/public/favicon-16x16.png +0 -0
  87. package/templates/tanstack-start/public/favicon-32x32.png +0 -0
  88. package/templates/tanstack-start/public/favicon.png +0 -0
  89. package/templates/tanstack-start/public/site.webmanifest +0 -19
  90. package/templates/tanstack-start/src/components/DefaultCatchBoundary.tsx +0 -53
  91. package/templates/tanstack-start/src/components/NotFound.tsx +0 -25
  92. package/templates/tanstack-start/src/components/PostError.tsx +0 -5
  93. package/templates/tanstack-start/src/components/UserError.tsx +0 -5
  94. package/templates/tanstack-start/src/routes/_pathlessLayout/_nested-layout/route-a.tsx +0 -10
  95. package/templates/tanstack-start/src/routes/_pathlessLayout/_nested-layout/route-b.tsx +0 -10
  96. package/templates/tanstack-start/src/routes/_pathlessLayout/_nested-layout.tsx +0 -34
  97. package/templates/tanstack-start/src/routes/_pathlessLayout.tsx +0 -16
  98. package/templates/tanstack-start/src/routes/api/users.$userId.ts +0 -28
  99. package/templates/tanstack-start/src/routes/api/users.ts +0 -59
  100. package/templates/tanstack-start/src/routes/customScript[.]js.ts +0 -10
  101. package/templates/tanstack-start/src/routes/deferred.tsx +0 -62
  102. package/templates/tanstack-start/src/routes/posts.$postId.tsx +0 -34
  103. package/templates/tanstack-start/src/routes/posts.index.tsx +0 -8
  104. package/templates/tanstack-start/src/routes/posts.tsx +0 -38
  105. package/templates/tanstack-start/src/routes/posts_.$postId.deep.tsx +0 -29
  106. package/templates/tanstack-start/src/routes/redirect.tsx +0 -9
  107. package/templates/tanstack-start/src/routes/users.$userId.tsx +0 -44
  108. package/templates/tanstack-start/src/routes/users.index.tsx +0 -18
  109. package/templates/tanstack-start/src/routes/users.tsx +0 -49
  110. package/templates/tanstack-start/src/styles/app.css +0 -12
  111. package/templates/tanstack-start/src/utils/loggingMiddleware.tsx +0 -41
  112. package/templates/tanstack-start/src/utils/posts.tsx +0 -40
  113. package/templates/tanstack-start/src/utils/seo.ts +0 -33
  114. package/templates/tanstack-start/src/utils/users.tsx +0 -5
@@ -1,3 +1,4 @@
1
+ import type { R2PutOptions } from "@cloudflare/workers-types/experimental/index.ts";
1
2
  import * as mf from "miniflare";
2
3
  import { isDeepStrictEqual } from "node:util";
3
4
  import type { Context } from "../context.ts";
@@ -253,6 +254,7 @@ export type R2ObjectMetadata = {
253
254
  etag: string;
254
255
  uploaded: Date;
255
256
  size: number;
257
+ httpMetadata?: R2HTTPMetadata;
256
258
  };
257
259
 
258
260
  export type R2ObjectContent = R2ObjectMetadata & {
@@ -272,7 +274,7 @@ export type PutR2ObjectResponse = {
272
274
  };
273
275
 
274
276
  export type R2Objects = {
275
- objects: R2ObjectMetadata[];
277
+ objects: Omit<R2ObjectMetadata, "httpMetadata">[];
276
278
  } & (
277
279
  | {
278
280
  truncated: true;
@@ -296,6 +298,7 @@ export type R2Bucket = _R2Bucket & {
296
298
  | string
297
299
  | null
298
300
  | Blob,
301
+ options?: Pick<R2PutOptions, "httpMetadata">,
299
302
  ): Promise<PutR2ObjectResponse>;
300
303
  delete(key: string): Promise<Response>;
301
304
  list(options?: R2ListOptions): Promise<R2Objects>;
@@ -449,7 +452,17 @@ export async function R2Bucket(
449
452
  ...bucket,
450
453
  head: async (key: string) => {
451
454
  if (isLocal) {
452
- return (await localBucket()).head(key);
455
+ const result = await (await localBucket()).head(key);
456
+ if (result) {
457
+ return {
458
+ key: result.key,
459
+ etag: result.etag,
460
+ uploaded: result.uploaded,
461
+ size: result.size,
462
+ httpMetadata: result.httpMetadata,
463
+ } as R2ObjectMetadata;
464
+ }
465
+ return null;
453
466
  }
454
467
  return headObject(api, {
455
468
  bucketName: bucket.name,
@@ -489,9 +502,9 @@ export async function R2Bucket(
489
502
  put: async (
490
503
  key: string,
491
504
  value: PutObjectObject,
505
+ options?: Pick<R2PutOptions, "httpMetadata">,
492
506
  ): Promise<PutR2ObjectResponse> => {
493
507
  if (isLocal) {
494
- // @ts-expect-error - node built-ins vs cloudflare built-ins
495
508
  return await (await localBucket()).put(
496
509
  key,
497
510
  typeof value === "string"
@@ -505,12 +518,14 @@ export async function R2Bucket(
505
518
  : value instanceof ReadableStream
506
519
  ? new Uint8Array(await streamToBuffer(value))
507
520
  : value,
521
+ options,
508
522
  );
509
523
  }
510
524
  const response = await putObject(api, {
511
525
  bucketName: bucket.name,
512
526
  key: key,
513
527
  object: value,
528
+ options: options,
514
529
  });
515
530
  const body = (await response.json()) as {
516
531
  result: {
@@ -546,6 +561,7 @@ const parseR2Object = (key: string, response: Response): R2ObjectContent => ({
546
561
  uploaded: parseDate(response.headers),
547
562
  key,
548
563
  size: Number(response.headers.get("Content-Length")),
564
+ httpMetadata: mapHeadersToHttpMetadata(response.headers),
549
565
  arrayBuffer: () => response.arrayBuffer(),
550
566
  bytes: () => response.bytes(),
551
567
  text: () => response.text(),
@@ -1155,6 +1171,7 @@ export async function headObject(
1155
1171
  etag: response.headers.get("ETag")?.replace(/"/g, "")!,
1156
1172
  uploaded: parseDate(response.headers),
1157
1173
  size: Number(response.headers.get("Content-Length")),
1174
+ httpMetadata: mapHeadersToHttpMetadata(response.headers),
1158
1175
  };
1159
1176
  }
1160
1177
 
@@ -1192,27 +1209,76 @@ export type PutObjectObject =
1192
1209
  | Buffer
1193
1210
  | Blob;
1194
1211
 
1212
+ function mapHttpMetadataToHeaders(
1213
+ httpMetadata: R2PutOptions["httpMetadata"],
1214
+ ): Record<string, string> {
1215
+ const headers: Record<string, string> = {};
1216
+
1217
+ if (httpMetadata instanceof Headers) {
1218
+ httpMetadata.forEach((value, key) => {
1219
+ headers[key] = value;
1220
+ });
1221
+ } else {
1222
+ const {
1223
+ contentType,
1224
+ contentLanguage,
1225
+ contentDisposition,
1226
+ contentEncoding,
1227
+ cacheControl,
1228
+ cacheExpiry,
1229
+ } = httpMetadata as R2HTTPMetadata;
1230
+ if (contentType) headers["Content-Type"] = contentType;
1231
+ if (contentLanguage) headers["Content-Language"] = contentLanguage;
1232
+ if (contentDisposition) headers["Content-Disposition"] = contentDisposition;
1233
+ if (contentEncoding) headers["Content-Encoding"] = contentEncoding;
1234
+ if (cacheControl) headers["Cache-Control"] = cacheControl;
1235
+ if (cacheExpiry) headers.Expires = cacheExpiry.toUTCString();
1236
+ }
1237
+
1238
+ return headers;
1239
+ }
1240
+
1241
+ function mapHeadersToHttpMetadata(headers: Headers): R2HTTPMetadata {
1242
+ return {
1243
+ contentType: headers.get("Content-Type") ?? undefined,
1244
+ contentLanguage: headers.get("Content-Language") ?? undefined,
1245
+ contentDisposition: headers.get("Content-Disposition") ?? undefined,
1246
+ contentEncoding: headers.get("Content-Encoding") ?? undefined,
1247
+ cacheControl: headers.get("Cache-Control") ?? undefined,
1248
+ cacheExpiry: headers.get("Expires")
1249
+ ? new Date(headers.get("Expires")!)
1250
+ : undefined,
1251
+ };
1252
+ }
1253
+
1195
1254
  export async function putObject(
1196
1255
  api: CloudflareApi,
1197
1256
  {
1198
1257
  bucketName,
1199
1258
  key,
1200
1259
  object,
1260
+ options,
1201
1261
  }: {
1202
1262
  bucketName: string;
1203
1263
  key: string;
1204
1264
  object: PutObjectObject;
1265
+ options?: Pick<R2PutOptions, "httpMetadata">;
1205
1266
  },
1206
1267
  ): Promise<Response> {
1207
1268
  // Using withExponentialBackoff for reliability
1208
1269
  return await withRetries(async () => {
1270
+ const headers: Record<string, string> = {
1271
+ "Content-Type": "application/octet-stream",
1272
+ ...(options?.httpMetadata
1273
+ ? mapHttpMetadataToHeaders(options?.httpMetadata)
1274
+ : {}),
1275
+ };
1276
+
1209
1277
  const response = await api.put(
1210
1278
  `/accounts/${api.accountId}/r2/buckets/${bucketName}/objects/${key}`,
1211
1279
  object,
1212
1280
  {
1213
- headers: {
1214
- "Content-Type": "application/octet-stream",
1215
- },
1281
+ headers,
1216
1282
  },
1217
1283
  );
1218
1284
  if (!response.ok) {
@@ -1044,13 +1044,13 @@ export async function createContainerApplicationRollout(
1044
1044
  );
1045
1045
  const result = (await response.json()) as {
1046
1046
  result: CreateRolloutApplicationResponse;
1047
- errors: { message: string }[];
1047
+ errors: { message: string; code: number }[];
1048
1048
  };
1049
1049
  if (response.ok) {
1050
1050
  return result.result;
1051
1051
  }
1052
1052
  throw Error(
1053
- `Failed to create container application rollout: ${result.errors.map((e: { message: string }) => e.message).join(", ")}`,
1053
+ `Failed to create container application rollout: ${result.errors.map((e) => `[${e.code}] ${e.message}`).join(", ")}`,
1054
1054
  );
1055
1055
  }
1056
1056
 
@@ -31,10 +31,7 @@ export async function createMiniflareWorkerProxy(options: {
31
31
  });
32
32
  server.on("request", async (req, res) => {
33
33
  try {
34
- const request = toMiniflareRequest(req);
35
- const name = options.getWorkerName(request);
36
- const worker = await options.miniflare.getWorker(name);
37
- const response = await worker.fetch(request);
34
+ const response = await handleFetch(req);
38
35
  writeMiniflareResponseToNode(response, res);
39
36
  } catch (error) {
40
37
  console.error(error);
@@ -45,20 +42,67 @@ export async function createMiniflareWorkerProxy(options: {
45
42
  }
46
43
  });
47
44
 
48
- const toMiniflareRequest = (req: http.IncomingMessage): miniflare.Request => {
45
+ const handleFetch = async (
46
+ req: http.IncomingMessage,
47
+ ): Promise<miniflare.Response> => {
48
+ const { request, url } = toMiniflareRequest(req);
49
+ const name = options.getWorkerName(request);
50
+ const worker = await options.miniflare.getWorker(name);
51
+
52
+ // Handle scheduled events
53
+ // https://developers.cloudflare.com/workers/runtime-apis/handlers/scheduled/#background
54
+ // https://github.com/cloudflare/workers-sdk/blob/7d53b9ab6b370944b7934ad51ebef43160c3c775/packages/wrangler/templates/middleware/middleware-scheduled.ts#L6
55
+ if (url.pathname === "/__scheduled") {
56
+ await worker.scheduled({
57
+ scheduledTime: new Date(),
58
+ cron: url.searchParams.get("cron") ?? undefined,
59
+ });
60
+ return new miniflare.Response("Ran scheduled function");
61
+ }
62
+
63
+ const result = await worker
64
+ .fetch(request)
65
+ .then((response) => ({ success: true, response }) as const)
66
+ .catch((error) => ({ success: false, error }) as const);
67
+
68
+ // If you open the `/__scheduled` page in a browser, the browser will automatically make a request to `/favicon.ico`.
69
+ // For scheduled Workers _without_ a fetch handler, this will result in an unhelpful error that we don't need to log.
70
+ // To avoid this, inject a 404 response to favicon.ico loads on the `/__scheduled` page
71
+ if (
72
+ request.headers.get("referer")?.endsWith("/__scheduled") &&
73
+ url.pathname === "/favicon.ico" &&
74
+ !result.success
75
+ ) {
76
+ return new miniflare.Response(null, { status: 404 });
77
+ }
78
+
79
+ if (!result.success) {
80
+ throw result.error;
81
+ }
82
+
83
+ return result.response;
84
+ };
85
+
86
+ const toMiniflareRequest = (
87
+ req: http.IncomingMessage,
88
+ ): { request: miniflare.Request; url: URL } => {
49
89
  const info = parseIncomingMessage(req);
50
90
  options.transformRequest?.(info);
51
- return new miniflare.Request(info.url, {
52
- method: info.method,
53
- headers: info.headers,
54
- body: info.body,
55
- redirect: info.redirect,
56
- duplex: info.duplex,
57
- });
91
+ return {
92
+ request: new miniflare.Request(info.url, {
93
+ method: info.method,
94
+ headers: info.headers,
95
+ body: info.body,
96
+ redirect: info.redirect,
97
+ duplex: info.duplex,
98
+ }),
99
+ url: info.url,
100
+ };
58
101
  };
59
102
 
60
103
  const createServerWebSocket = async (req: http.IncomingMessage) => {
61
- const name = options.getWorkerName(toMiniflareRequest(req));
104
+ const { request } = toMiniflareRequest(req);
105
+ const name = options.getWorkerName(request);
62
106
  const target = await options.miniflare.unsafeGetDirectURL(name);
63
107
  const url = new URL(req.url ?? "/", target);
64
108
  url.protocol = url.protocol.replace("http", "ws");
@@ -1,30 +1,9 @@
1
- import type { GetPlatformProxyOptions } from "wrangler";
2
- import { dedent } from "../../util/dedent.ts";
1
+ import type { PluginConfig } from "@cloudflare/vite-plugin";
2
+ import alchemyVite from "../vite/plugin.ts";
3
3
 
4
- /**
5
- * TanStackStart server functions and middleware run in Node.js intead of Miniflare when using `vite dev`.
6
- *
7
- * This plugin polyfills the cloudflare:workers module & includes `process.env` during the dev server phase.
8
- *
9
- * @see https://developers.cloudflare.com/workers/framework-guides/web-apps/tanstack/#using-cloudflare-bindings
10
- */
11
- export default function alchemy(options: GetPlatformProxyOptions = {}) {
12
- return {
13
- name: "cloudflare-workers-dev-shim",
14
- apply: "serve", // dev‑only
15
- enforce: "pre",
16
- resolveId(id: string) {
17
- if (id === "cloudflare:workers") return id; // tell Vite we handled it
18
- },
19
- load(id: string) {
20
- if (id === "cloudflare:workers") {
21
- return dedent`
22
- import { getCloudflareEnvProxy } from "alchemy/cloudflare";
23
- export const env = await getCloudflareEnvProxy(${JSON.stringify(
24
- options,
25
- )});
26
- `;
27
- }
28
- },
29
- } as const;
4
+ export default function alchemy(options: PluginConfig = {}) {
5
+ return alchemyVite({
6
+ viteEnvironment: { name: "ssr" },
7
+ ...options,
8
+ });
30
9
  }
@@ -15,10 +15,21 @@ export async function TanStackStart<B extends Bindings>(
15
15
  props?: Partial<TanStackStartProps<B>>,
16
16
  ): Promise<TanStackStart<B>> {
17
17
  return await Vite(id, {
18
- ...props,
18
+ entrypoint: "dist/server/index.js",
19
+ assets: "dist/client",
20
+ compatibility: "node",
19
21
  noBundle: true,
20
- entrypoint: props?.entrypoint ?? ".output/server/index.mjs",
21
- compatibilityFlags: ["nodejs_compat", ...(props?.compatibilityFlags ?? [])],
22
- assets: props?.assets ?? ".output/public",
22
+ spa: false,
23
+ ...props,
24
+ wrangler: {
25
+ ...props?.wrangler,
26
+ transform: async (spec) => {
27
+ const transformed = (await props?.wrangler?.transform?.(spec)) ?? spec;
28
+ if (!props?.wrangler?.main) {
29
+ transformed.main = "@tanstack/react-start/server-entry";
30
+ }
31
+ return transformed;
32
+ },
33
+ },
23
34
  });
24
35
  }
@@ -334,6 +334,7 @@ export async function Website<B extends Bindings>(
334
334
  // NOTE: we must set this to ensure the user does not accidentally set `NODE_ENV=production`
335
335
  // which breaks `vite dev` (it won't, for example, re-write `process.env.TSS_APP_BASE` in the `.js` client side bundle)
336
336
  NODE_ENV: "development",
337
+ ALCHEMY_ROOT: Scope.current.rootDir,
337
338
  },
338
339
  });
339
340
  }
@@ -18,7 +18,7 @@ import type {
18
18
  MultiStepMigration,
19
19
  SingleStepMigration,
20
20
  } from "./worker-migration.ts";
21
- import type { AssetsConfig, WorkerProps } from "./worker.ts";
21
+ import { isWorker, type AssetsConfig, type WorkerProps } from "./worker.ts";
22
22
 
23
23
  /**
24
24
  * Metadata returned by Cloudflare API for a worker script
@@ -175,6 +175,7 @@ export interface WorkerDefaultEnvironment extends WorkerEnvironment {
175
175
  script: WorkerScriptInfo;
176
176
  }
177
177
 
178
+ //? (jacob): why is this not using BaseWorkerProps?
178
179
  export interface WorkerMetadata {
179
180
  compatibility_date: string;
180
181
  compatibility_flags?: string[];
@@ -202,6 +203,7 @@ export interface WorkerMetadata {
202
203
  limits?: {
203
204
  cpu_ms?: number;
204
205
  };
206
+ tail_consumers?: Array<Worker | { service: string }>;
205
207
  }
206
208
 
207
209
  export async function prepareWorkerMetadata(
@@ -316,6 +318,9 @@ export async function prepareWorkerMetadata(
316
318
  const meta: WorkerMetadata = {
317
319
  compatibility_date: props.compatibilityDate,
318
320
  compatibility_flags: props.compatibilityFlags,
321
+ tail_consumers: props.tailConsumers?.map((consumer) =>
322
+ isWorker(consumer) ? { service: consumer.name } : consumer,
323
+ ),
319
324
  bindings: [],
320
325
  observability: {
321
326
  enabled: props.observability?.enabled !== false,
@@ -375,6 +375,11 @@ export interface BaseWorkerProps<
375
375
  */
376
376
  cpu_ms?: number;
377
377
  };
378
+
379
+ /**
380
+ * Tail consumers that will receive execution logs from this worker
381
+ */
382
+ tailConsumers?: Array<Worker | { service: string }>;
378
383
  }
379
384
 
380
385
  export interface InlineWorkerProps<
@@ -1018,6 +1023,7 @@ const _Worker = Resource(
1018
1023
  props.crons?.map((cron) => ({ cron })) ?? [],
1019
1024
  );
1020
1025
  }
1026
+
1021
1027
  await Promise.all(
1022
1028
  options.workflows.map((workflow) =>
1023
1029
  upsertWorkflow(api, {
@@ -1063,6 +1069,7 @@ const _Worker = Resource(
1063
1069
  url: subdomain?.url,
1064
1070
  assets: props.assets,
1065
1071
  crons: props.crons,
1072
+ tailConsumers: props.tailConsumers,
1066
1073
  routes,
1067
1074
  domains,
1068
1075
  namespace: props.namespace,
@@ -11,7 +11,7 @@ import type { DurableObjectNamespace } from "./durable-object-namespace.ts";
11
11
  import type { EventSource } from "./event-source.ts";
12
12
  import { isQueueEventSource } from "./event-source.ts";
13
13
  import { isQueue } from "./queue.ts";
14
- import type { Worker, WorkerProps } from "./worker.ts";
14
+ import { isWorker, type Worker, type WorkerProps } from "./worker.ts";
15
15
 
16
16
  type WranglerJsonRateLimit = Omit<WorkerBindingRateLimit, "type"> & {
17
17
  type: "rate_limit";
@@ -182,6 +182,15 @@ export async function WranglerJson(
182
182
  spec.vars = { ...worker.env };
183
183
  }
184
184
 
185
+ if (worker.tailConsumers && worker.tailConsumers.length > 0) {
186
+ spec.tail_consumers = worker.tailConsumers.map((consumer) => {
187
+ if (isWorker(consumer)) {
188
+ return { service: consumer.name };
189
+ }
190
+ return { service: consumer.service };
191
+ });
192
+ }
193
+
185
194
  if (worker.crons && worker.crons.length > 0) {
186
195
  spec.triggers = { crons: worker.crons };
187
196
  }
@@ -485,6 +494,11 @@ export interface WranglerJsonSpec {
485
494
  experimental_remote?: boolean;
486
495
  }[];
487
496
 
497
+ /**
498
+ * Tail consumers that will receive execution logs from this worker
499
+ */
500
+ tail_consumers?: Array<{ service: string }>;
501
+
488
502
  /**
489
503
  * Unsafe bindings section for experimental features
490
504
  */
package/src/docker/api.ts CHANGED
@@ -3,6 +3,41 @@ import { Buffer } from "node:buffer";
3
3
  import { spawn } from "node:child_process";
4
4
  import fs from "node:fs/promises";
5
5
  import path from "node:path";
6
+ import type { Duration, HealthcheckConfig } from "./container.ts";
7
+
8
+ /**
9
+ * Normalize a duration value to Docker CLI format
10
+ * Accepts either a number (seconds) or a string with unit suffix (ms|s|m|h)
11
+ * Returns a string in Docker CLI format
12
+ *
13
+ * The Duration type provides compile-time type safety, ensuring only valid
14
+ * formats are accepted: number or string matching the pattern `${number}${unit}`
15
+ *
16
+ * @param duration Duration value (number in seconds or string with unit)
17
+ * @returns Normalized duration string for Docker CLI
18
+ *
19
+ * @example
20
+ * normalizeDuration(30) // "30s"
21
+ * normalizeDuration("30s") // "30s"
22
+ * normalizeDuration("1m") // "1m"
23
+ * normalizeDuration("500ms") // "500ms"
24
+ * normalizeDuration("1.5s") // "1.5s"
25
+ */
26
+ export function normalizeDuration(duration: Duration): string {
27
+ if (typeof duration === "number") {
28
+ return `${duration}s`;
29
+ }
30
+
31
+ // Validate string format: must be a number followed by unit (ms|s|m|h)
32
+ const match = duration.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)$/);
33
+ if (!match) {
34
+ throw new Error(
35
+ `Invalid duration format: "${duration}". Expected format: number followed by unit (ms|s|m|h), e.g., "30s", "1m", "500ms"`,
36
+ );
37
+ }
38
+
39
+ return duration;
40
+ }
6
41
 
7
42
  /**
8
43
  * Options for Docker API requests
@@ -59,7 +94,10 @@ export class DockerApi {
59
94
  * @param args Command arguments to pass to Docker CLI
60
95
  * @returns Result of the command
61
96
  */
62
- async exec(args: string[]): Promise<{ stdout: string; stderr: string }> {
97
+ async exec(
98
+ args: string[],
99
+ remainingAttempts = 3,
100
+ ): Promise<{ stdout: string; stderr: string }> {
63
101
  // If a custom config directory is provided, ensure all commands use it by
64
102
  // setting the DOCKER_CONFIG env variable for the spawned process.
65
103
  const env = this.configDir
@@ -94,8 +132,16 @@ export class DockerApi {
94
132
  try {
95
133
  await subprocess;
96
134
  } catch (error: any) {
135
+ const message = stderr || error.message || "Command failed";
136
+ if (
137
+ message.includes("unexpected status from HEAD request") &&
138
+ remainingAttempts > 0
139
+ ) {
140
+ await new Promise((resolve) => setTimeout(resolve, 1000));
141
+ return this.exec(args, remainingAttempts - 1);
142
+ }
97
143
  // Process failed, but we still have the output
98
- throw new Error(stderr || error.message || "Command failed");
144
+ throw new Error(message);
99
145
  }
100
146
 
101
147
  return { stdout, stderr };
@@ -177,6 +223,7 @@ export class DockerApi {
177
223
  env?: Record<string, string>;
178
224
  volumes?: Record<string, string>;
179
225
  cmd?: string[];
226
+ healthcheck?: HealthcheckConfig;
180
227
  } = {},
181
228
  ): Promise<string> {
182
229
  const args = ["create", "--name", name];
@@ -202,6 +249,43 @@ export class DockerApi {
202
249
  }
203
250
  }
204
251
 
252
+ // Add healthcheck configuration
253
+ if (options.healthcheck) {
254
+ const hc = options.healthcheck;
255
+
256
+ // Format the cmd
257
+ const cmdStr = Array.isArray(hc.cmd) ? hc.cmd.join(" ") : hc.cmd;
258
+ args.push("--health-cmd", cmdStr);
259
+
260
+ // Add interval
261
+ if (hc.interval !== undefined) {
262
+ args.push("--health-interval", normalizeDuration(hc.interval));
263
+ }
264
+
265
+ // Add timeout
266
+ if (hc.timeout !== undefined) {
267
+ args.push("--health-timeout", normalizeDuration(hc.timeout));
268
+ }
269
+
270
+ // Add retries
271
+ if (hc.retries !== undefined) {
272
+ args.push("--health-retries", String(hc.retries));
273
+ }
274
+
275
+ // Add start period
276
+ if (hc.startPeriod !== undefined) {
277
+ args.push("--health-start-period", normalizeDuration(hc.startPeriod));
278
+ }
279
+
280
+ // Add start interval (API 1.44+)
281
+ if (hc.startInterval !== undefined) {
282
+ args.push(
283
+ "--health-start-interval",
284
+ normalizeDuration(hc.startInterval),
285
+ );
286
+ }
287
+ }
288
+
205
289
  args.push(image);
206
290
 
207
291
  // Add command if specified
@@ -59,6 +59,62 @@ export interface NetworkMapping {
59
59
  aliases?: string[];
60
60
  }
61
61
 
62
+ /**
63
+ * Duration value supporting both number (seconds) and string format (value + unit)
64
+ * Units: ms (milliseconds), s (seconds), m (minutes), h (hours)
65
+ * Examples: 30, "30s", "1m", "500ms", "2h"
66
+ */
67
+ export type Duration = number | `${number}${"ms" | "s" | "m" | "h"}`;
68
+
69
+ /**
70
+ * Healthcheck configuration
71
+ */
72
+ export interface HealthcheckConfig {
73
+ /**
74
+ * Command to run to check health.
75
+ * Can be an array of command arguments or a shell command string.
76
+ * Examples:
77
+ * - ["curl", "-f", "http://localhost/"]
78
+ * - "curl -f http://localhost/ || exit 1"
79
+ */
80
+ cmd: string[] | string;
81
+
82
+ /**
83
+ * Time between running the check
84
+ * Can be a number (in seconds) or string with unit (e.g., "30s", "1m")
85
+ * @default 0
86
+ */
87
+ interval?: Duration;
88
+
89
+ /**
90
+ * Maximum time to allow one check to run
91
+ * Can be a number (in seconds) or string with unit (e.g., "10s", "500ms")
92
+ * @default 0
93
+ */
94
+ timeout?: Duration;
95
+
96
+ /**
97
+ * Consecutive failures needed to report unhealthy
98
+ */
99
+ retries?: number;
100
+
101
+ /**
102
+ * Start period for the container to initialize before starting
103
+ * health-retries countdown
104
+ * Can be a number (in seconds) or string with unit (e.g., "40s", "1m")
105
+ * @default 0
106
+ */
107
+ startPeriod?: Duration;
108
+
109
+ /**
110
+ * Time between running the check during the start period
111
+ * Can be a number (in seconds) or string with unit (e.g., "5s", "500ms")
112
+ * Requires Docker API 1.44+
113
+ * @default 0
114
+ */
115
+ startInterval?: Duration;
116
+ }
117
+
62
118
  /**
63
119
  * Properties for creating a Docker container
64
120
  */
@@ -115,6 +171,11 @@ export interface ContainerProps {
115
171
  * Start the container after creation
116
172
  */
117
173
  start?: boolean;
174
+
175
+ /**
176
+ * Healthcheck configuration
177
+ */
178
+ healthcheck?: HealthcheckConfig;
118
179
  }
119
180
 
120
181
  /**
@@ -172,6 +233,41 @@ export interface Container extends ContainerProps {
172
233
  * restart: "always",
173
234
  * start: true
174
235
  * });
236
+ *
237
+ * @example
238
+ * // Create a container with healthcheck using numeric values (seconds)
239
+ * const healthyContainer = await Container("api", {
240
+ * image: "my-api:latest",
241
+ * ports: [
242
+ * { external: 3000, internal: 3000 }
243
+ * ],
244
+ * healthcheck: {
245
+ * cmd: ["curl", "-f", "http://localhost:3000/health"],
246
+ * interval: 30,
247
+ * timeout: 10,
248
+ * retries: 3,
249
+ * startPeriod: 40
250
+ * },
251
+ * start: true
252
+ * });
253
+ *
254
+ * @example
255
+ * // Create a container with healthcheck using string duration format
256
+ * const healthyContainer2 = await Container("api2", {
257
+ * image: "my-api:latest",
258
+ * ports: [
259
+ * { external: 3001, internal: 3000 }
260
+ * ],
261
+ * healthcheck: {
262
+ * cmd: ["curl", "-f", "http://localhost:3000/health"],
263
+ * interval: "30s",
264
+ * timeout: "10s",
265
+ * retries: 3,
266
+ * startPeriod: "1m",
267
+ * startInterval: "500ms"
268
+ * },
269
+ * start: true
270
+ * });
175
271
  */
176
272
  export const Container = Resource(
177
273
  "docker::Container",
@@ -245,6 +341,7 @@ export const Container = Resource(
245
341
  env: props.environment,
246
342
  volumes: volumeMappings,
247
343
  cmd: props.command,
344
+ healthcheck: props.healthcheck,
248
345
  });
249
346
 
250
347
  // Connect to networks if specified