@cosmicdrift/kumiko-dev-server 0.193.1 → 0.194.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.193.1",
3
+ "version": "0.194.0",
4
4
  "description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. Not shipped into production node_modules — see @cosmicdrift/kumiko-server-runtime for the prod boot path.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -54,9 +54,9 @@
54
54
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
55
55
  },
56
56
  "dependencies": {
57
- "@cosmicdrift/kumiko-bundled-features": "0.193.1",
58
- "@cosmicdrift/kumiko-framework": "0.193.1",
59
- "@cosmicdrift/kumiko-server-runtime": "0.193.1",
57
+ "@cosmicdrift/kumiko-bundled-features": "0.194.0",
58
+ "@cosmicdrift/kumiko-framework": "0.194.0",
59
+ "@cosmicdrift/kumiko-server-runtime": "0.194.0",
60
60
  "ts-morph": "^28.0.0"
61
61
  },
62
62
  "publishConfig": {
@@ -47,21 +47,40 @@ afterAll(() => {
47
47
  * implicitly assume "this many ms is enough", which is brittle on
48
48
  * loaded CI runners. The polling form converges as fast as the system
49
49
  * allows AND fails loudly with a useful message if the event never lands.
50
+ *
51
+ * `retry`/`retryIntervalMs`: for predicates gated on a native `fs.watch`
52
+ * event, a single write only gets one chance at delivery — under macOS
53
+ * FSEvents backlog (hundreds of concurrent recursive watches in a full
54
+ * `bun test` run), events aren't just delayed, they're sometimes dropped
55
+ * entirely, and Node doesn't surface a rescan signal. `retry` re-fires the
56
+ * triggering action on a cadence so a dropped event costs one interval,
57
+ * not the whole timeout. `retryIntervalMs` must stay well above the
58
+ * watcher's `debounceMs` — a retry that lands mid-debounce just resets
59
+ * the timer and can starve `fire()` forever.
50
60
  */
51
61
  async function waitFor(
52
62
  predicate: () => boolean,
53
- opts: { timeout?: number; interval?: number; label?: string } = {},
63
+ opts: {
64
+ timeout?: number;
65
+ interval?: number;
66
+ label?: string;
67
+ retry?: () => void;
68
+ retryIntervalMs?: number;
69
+ } = {},
54
70
  ): Promise<void> {
55
- // Default 5000ms — fchokidar-FS-watch events take >2s under CI load on
56
- // the cdgs-runner (Memory feedback_watch_test_flaky, observed 3× in
57
- // a row on PR #80). 5s gives headroom without slowing the happy-path.
58
71
  const timeout = opts.timeout ?? 5000;
59
72
  const interval = opts.interval ?? 25;
73
+ const retryIntervalMs = opts.retryIntervalMs ?? 250;
60
74
  const deadline = Date.now() + timeout;
75
+ let nextRetryAt = Date.now() + retryIntervalMs;
61
76
  while (!predicate()) {
62
77
  if (Date.now() > deadline) {
63
78
  throw new Error(`waitFor: ${opts.label ?? "predicate"} not satisfied within ${timeout}ms`);
64
79
  }
80
+ if (opts.retry && Date.now() >= nextRetryAt) {
81
+ opts.retry();
82
+ nextRetryAt = Date.now() + retryIntervalMs;
83
+ }
65
84
  await new Promise((r) => setTimeout(r, interval));
66
85
  }
67
86
  }
@@ -105,11 +124,11 @@ describe("watchAndRegenerate", () => {
105
124
  expect(results).toHaveLength(1);
106
125
  expect(results[0]?.eventCount).toBe(1);
107
126
 
108
- // Add a second event-definition by rewriting the feature.
109
- writeFile(
110
- appRoot,
111
- "src/feature.ts",
112
- `
127
+ const rewrite = () =>
128
+ writeFile(
129
+ appRoot,
130
+ "src/feature.ts",
131
+ `
113
132
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
114
133
  import { z } from "zod";
115
134
 
@@ -118,20 +137,29 @@ export default defineFeature("orders", (r) => {
118
137
  r.defineEvent("second", z.object({ tag: z.string() }));
119
138
  });
120
139
  `,
121
- );
122
-
123
- // Poll until the watcher's debounced re-run has landed. fs.watch
124
- // events on macOS arrive in 1-5ms; CI runners can stretch that —
125
- // polling adapts to the actual schedule instead of guessing a fixed
126
- // sleep.
127
- await waitFor(() => results.length >= 2, {
128
- timeout: 5000,
129
- label: "second codegen result",
130
- });
140
+ );
131
141
 
132
- expect(results.at(-1)?.eventCount).toBe(2);
133
- handle.close();
134
- });
142
+ // Add a second event-definition by rewriting the feature.
143
+ rewrite();
144
+
145
+ // Poll until the watcher's debounced re-run has landed, re-touching
146
+ // the file every 250ms in case the triggering fs.watch event was
147
+ // dropped rather than merely delayed (see waitFor's `retry` doc).
148
+ // Waits on the expected *state* (eventCount 2), not just a length
149
+ // bump — writeFileSync truncates then writes, so an event fired
150
+ // mid-write would satisfy a length-only predicate with a stale
151
+ // (0 or 1) eventCount, especially with retries widening that window.
152
+ try {
153
+ await waitFor(() => results.some((r) => r.eventCount === 2), {
154
+ timeout: 12000,
155
+ label: "second codegen result",
156
+ retry: rewrite,
157
+ });
158
+ expect(results.at(-1)?.eventCount).toBe(2);
159
+ } finally {
160
+ handle.close();
161
+ }
162
+ }, 15000);
135
163
 
136
164
  test("close() is idempotent", () => {
137
165
  const appRoot = makeAppDir();
@@ -174,15 +202,20 @@ export default defineFeature("orders", (r) => {
174
202
 
175
203
  // Positive control: a .ts change MUST trigger. waitFor exits as
176
204
  // soon as the new result lands, confirming the watcher is alive.
177
- writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("ignore-css", "after"));
205
+ // Re-touched on retry in case the triggering event is dropped
206
+ // rather than merely delayed under a loaded fs.watch backlog.
207
+ const triggerRewrite = () =>
208
+ writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("ignore-css", "after"));
209
+ triggerRewrite();
178
210
  await waitFor(() => results.length > afterNonTs, {
179
- timeout: 5000,
211
+ timeout: 12000,
180
212
  label: "ts-change result after non-ts noise",
213
+ retry: triggerRewrite,
181
214
  });
182
215
 
183
216
  // The non-ts writes should not have advanced the count past the
184
217
  // baseline. If they did, the watcher's filter is broken.
185
218
  expect(afterNonTs).toBe(baseline);
186
219
  handle.close();
187
- });
220
+ }, 15000);
188
221
  });
@@ -153,9 +153,9 @@ export type CreateKumikoServerOptions = {
153
153
  * `stylesheet: false` → CSS-Pipeline explizit deaktivieren. */
154
154
  readonly stylesheet?: string | false;
155
155
  /** Optional HTML template served at `GET /`. The dev-server injects
156
- * a `<script src="/client.js">` and a reload-listener snippet into
157
- * `</body>` if those aren't already there. Defaults to a minimal
158
- * empty-body document — enough to boot the client. */
156
+ * a `<script type="module" src="/client.js">` and a reload-listener
157
+ * snippet into `</body>` if those aren't already there. Defaults to a
158
+ * minimal empty-body document — enough to boot the client. */
159
159
  readonly htmlPath?: string;
160
160
  /** Port to listen on. Default 4173. Overridable via `PORT` env. */
161
161
  readonly port?: number;