@fuzdev/fuz_app 0.18.0 → 0.20.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.
@@ -1 +1 @@
1
- {"version":3,"file":"action_event.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_event.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAC,gBAAgB,EAAc,eAAe,EAAC,MAAM,kBAAkB,CAAC;AAWpF,OAAO,KAAK,EACX,cAAc,EACd,sBAAsB,EACtB,mBAAmB,EAEnB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAC,sBAAsB,EAAE,eAAe,EAAC,MAAM,yBAAyB,CAAC;AACrF,OAAO,EAAkB,KAAK,oBAAoB,EAAC,MAAM,wBAAwB,CAAC;AAwBlF,MAAM,MAAM,yBAAyB,CAAC,OAAO,SAAS,MAAM,GAAG,MAAM,IAAI,CACxE,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,EACvC,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,EACvC,KAAK,EAAE,WAAW,CAAC,OAAO,CAAC,KACvB,IAAI,CAAC;AAEV;;GAEG;AACH,qBAAa,WAAW,CACvB,OAAO,SAAS,MAAM,GAAG,MAAM,EAC/B,MAAM,SAAS,gBAAgB,GAAG,gBAAgB,EAClD,KAAK,SAAS,eAAe,GAAG,eAAe;;IAK/C,QAAQ,CAAC,WAAW,EAAE,sBAAsB,CAAC;IAC7C,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAE/B,IAAI,IAAI,IAAI,oBAAoB,CAAC,OAAO,CAAC,GAAG;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,KAAK,CAAA;KAAC,CAEvE;gBAGA,WAAW,EAAE,sBAAsB,EACnC,IAAI,EAAE,eAAe,EACrB,IAAI,EAAE,oBAAoB,CAAC,OAAO,CAAC;IAOpC,MAAM,IAAI,oBAAoB,CAAC,OAAO,CAAC;IAMvC,OAAO,CAAC,QAAQ,EAAE,yBAAyB,CAAC,OAAO,CAAC,GAAG,MAAM,IAAI;IAKjE,QAAQ,CAAC,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,GAAG,IAAI;IAUvD;;OAEG;IACH,KAAK,IAAI,IAAI;IA8Cb;;OAEG;IAGG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IA0CnC;;OAEG;IACH,WAAW,IAAI,IAAI;IAkCnB;;OAEG;IACH,UAAU,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI;IAezC,WAAW,IAAI,OAAO;IAItB,eAAe,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI;IAIxC,WAAW,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI;IAQ1C,YAAY,CAAC,QAAQ,EAAE,sBAAsB,GAAG,IAAI;IAUpD,gBAAgB,CAAC,YAAY,EAAE,mBAAmB,GAAG,IAAI;CAyKzD;AAGD;;GAEG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,SAAS,MAAM,GAAG,MAAM,EAClE,aAAa,sBAAsB,EACnC,MAAM,eAAe,EACrB,OAAO,OAAO,EACd,gBAAgB,gBAAgB,KAC9B,WAAW,CAAC,OAAO,CAiBrB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,6BAA6B,GAAI,OAAO,SAAS,MAAM,GAAG,MAAM,EAC5E,MAAM,oBAAoB,CAAC,OAAO,CAAC,EACnC,aAAa,sBAAsB,KACjC,WAAW,CAAC,OAAO,CAOrB,CAAC;AAIF,eAAO,MAAM,kBAAkB,GAC9B,UAAU,OAAO,EACjB,aAAa,sBAAsB,KACjC,WAGF,CAAC"}
1
+ {"version":3,"file":"action_event.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/action_event.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,OAAO,KAAK,EAAC,gBAAgB,EAAc,eAAe,EAAC,MAAM,kBAAkB,CAAC;AAWpF,OAAO,KAAK,EACX,cAAc,EACd,sBAAsB,EACtB,mBAAmB,EAEnB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAC,sBAAsB,EAAE,eAAe,EAAC,MAAM,yBAAyB,CAAC;AACrF,OAAO,EAAkB,KAAK,oBAAoB,EAAC,MAAM,wBAAwB,CAAC;AAelF,MAAM,MAAM,yBAAyB,CAAC,OAAO,SAAS,MAAM,GAAG,MAAM,IAAI,CACxE,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,EACvC,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,EACvC,KAAK,EAAE,WAAW,CAAC,OAAO,CAAC,KACvB,IAAI,CAAC;AAEV;;GAEG;AACH,qBAAa,WAAW,CACvB,OAAO,SAAS,MAAM,GAAG,MAAM,EAC/B,MAAM,SAAS,gBAAgB,GAAG,gBAAgB,EAClD,KAAK,SAAS,eAAe,GAAG,eAAe;;IAK/C,QAAQ,CAAC,WAAW,EAAE,sBAAsB,CAAC;IAC7C,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAE/B,IAAI,IAAI,IAAI,oBAAoB,CAAC,OAAO,CAAC,GAAG;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,KAAK,CAAA;KAAC,CAEvE;gBAGA,WAAW,EAAE,sBAAsB,EACnC,IAAI,EAAE,eAAe,EACrB,IAAI,EAAE,oBAAoB,CAAC,OAAO,CAAC;IAOpC,MAAM,IAAI,oBAAoB,CAAC,OAAO,CAAC;IAMvC,OAAO,CAAC,QAAQ,EAAE,yBAAyB,CAAC,OAAO,CAAC,GAAG,MAAM,IAAI;IAKjE,QAAQ,CAAC,QAAQ,EAAE,oBAAoB,CAAC,OAAO,CAAC,GAAG,IAAI;IAUvD;;OAEG;IACH,KAAK,IAAI,IAAI;IA8Cb;;OAEG;IAGG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IA0CnC;;OAEG;IACH,WAAW,IAAI,IAAI;IAkCnB;;OAEG;IACH,UAAU,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI;IAezC,WAAW,IAAI,OAAO;IAItB,eAAe,CAAC,QAAQ,EAAE,OAAO,GAAG,IAAI;IAIxC,WAAW,CAAC,OAAO,EAAE,cAAc,GAAG,IAAI;IAQ1C,YAAY,CAAC,QAAQ,EAAE,sBAAsB,GAAG,IAAI;IAUpD,gBAAgB,CAAC,YAAY,EAAE,mBAAmB,GAAG,IAAI;CAyKzD;AAGD;;GAEG;AACH,eAAO,MAAM,mBAAmB,GAAI,OAAO,SAAS,MAAM,GAAG,MAAM,EAClE,aAAa,sBAAsB,EACnC,MAAM,eAAe,EACrB,OAAO,OAAO,EACd,gBAAgB,gBAAgB,KAC9B,WAAW,CAAC,OAAO,CAiBrB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,6BAA6B,GAAI,OAAO,SAAS,MAAM,GAAG,MAAM,EAC5E,MAAM,oBAAoB,CAAC,OAAO,CAAC,EACnC,aAAa,sBAAsB,KACjC,WAAW,CAAC,OAAO,CAOrB,CAAC;AAIF,eAAO,MAAM,kBAAkB,GAC9B,UAAU,OAAO,EACjB,aAAa,sBAAsB,KACjC,WAGF,CAAC"}
@@ -12,13 +12,6 @@ import { jsonrpc_error_messages, ThrownJsonrpcError } from '../http/jsonrpc_erro
12
12
  import { ActionEventData } from './action_event_data.js';
13
13
  import { validate_step_transition, validate_phase_transition, should_validate_output, is_action_complete, create_initial_data, get_initial_phase, is_request_response, is_send_request_with_parsed_input, is_notification_send_with_parsed_input, } from './action_event_helpers.js';
14
14
  import { create_uuid } from '../uuid.js';
15
- /** Formats a Zod validation error with field paths for clearer error messages. */
16
- const format_zod_validation_error = (error) => error.issues
17
- .map((i) => {
18
- const path = i.path.length > 0 ? `${i.path.join('.')}: ` : '';
19
- return `${path}${i.message}`;
20
- })
21
- .join(', ');
22
15
  /**
23
16
  * Action event that manages the lifecycle of an action through its state machine.
24
17
  */
@@ -87,7 +80,7 @@ export class ActionEvent {
87
80
  // Handler errors (network, server, business logic) DO transition to error phases.
88
81
  this.#fail(
89
82
  // no need to protect this info
90
- jsonrpc_error_messages.invalid_params(`failed to parse input: ${format_zod_validation_error(parsed.error)}`, { validation_errors: parsed.error.issues }));
83
+ jsonrpc_error_messages.invalid_params(`failed to parse input: ${z.prettifyError(parsed.error)}`, { validation_errors: parsed.error.issues }));
91
84
  }
92
85
  return this;
93
86
  }
@@ -281,7 +274,7 @@ export class ActionEvent {
281
274
  this.#transition_step('handled', { output: parsed.data });
282
275
  }
283
276
  else {
284
- this.#fail(jsonrpc_error_messages.validation_error(`failed to parse output: ${format_zod_validation_error(parsed.error)}`, { output, validation_errors: parsed.error.issues }));
277
+ this.#fail(jsonrpc_error_messages.validation_error(`failed to parse output: ${z.prettifyError(parsed.error)}`, { output, validation_errors: parsed.error.issues }));
285
278
  }
286
279
  }
287
280
  else {
@@ -78,8 +78,38 @@ export declare class FrontendWebsocketClient implements WebsocketConnection, Dis
78
78
  last_close_code: number | null;
79
79
  /** Reason string from the most recent close event (may be empty). */
80
80
  last_close_reason: string | null;
81
+ /**
82
+ * The error thrown by the most recent attempted `send()`, or `null` if the
83
+ * most recent attempt succeeded or none has been attempted yet. Populated
84
+ * when the underlying `ws.send` throws (e.g., buffer full, serialization
85
+ * error); reset to `null` on the next successful send. Not touched when
86
+ * `send()` short-circuits because the socket is not connected — consult
87
+ * {@link connected} for that case. Wrappers surfacing per-message failure
88
+ * reasons can read this after a `false` return from `send()`.
89
+ */
90
+ last_send_error: Error | null;
81
91
  readonly connected: boolean;
82
92
  constructor(url: string, options?: FrontendWebsocketClientOptions);
93
+ /**
94
+ * Swap the auto-reconnect policy in place. Accepts the same shape as the
95
+ * constructor's `reconnect` option: `false` disables reconnect, `true` or
96
+ * `null`/omitted restores the defaults, or a config object customizes
97
+ * specific fields (missing fields fall back to defaults, not "keep
98
+ * current" — each call defines the whole policy atomically, same as the
99
+ * constructor).
100
+ *
101
+ * In-flight reconnect schedules are **monotonically shortened**: the
102
+ * effective total wait from arm-time never exceeds what the new policy
103
+ * prescribes. If the new target is already past the time already
104
+ * elapsed, the reconnect fires immediately (on the next tick). The wait
105
+ * is never extended.
106
+ *
107
+ * Turning reconnect off while a reconnect timer is pending cancels that
108
+ * timer and transitions status to `closed` (since the lie of
109
+ * `'reconnecting'` would be visible to UI indicators). Turning it back on
110
+ * does not synthesize a reconnect — wait for the next close.
111
+ */
112
+ set_reconnect(reconnect?: boolean | FrontendWebsocketReconnectOptions | null): void;
83
113
  get url(): string;
84
114
  /**
85
115
  * Whether the server has permanently closed the session. Once `true`, all
@@ -1 +1 @@
1
- {"version":3,"file":"socket.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/socket.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAGpD,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,oBAAoB,CAAC;AAE5D,qDAAqD;AACrD,eAAO,MAAM,kBAAkB,OAAO,CAAC;AACvC,kCAAkC;AAClC,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAC5C,8DAA8D;AAC9D,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AACjD,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,QAAQ,CAAC;AAE9F,MAAM,MAAM,oBAAoB,GAAG,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;AACjE,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;AAExD,MAAM,WAAW,iCAAiC;IACjD,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,8BAA8B;IAC9C;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,iCAAiC,GAAG,IAAI,CAAC;IAC/D,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAED;;;;;;;;;;GAUG;AACH,qBAAa,uBAAwB,YAAW,mBAAmB,EAAE,UAAU;;IAQ9E,EAAE,EAAE,SAAS,GAAG,IAAI,CAAoB;IACxC,MAAM,EAAE,YAAY,CAAyB;IAE7C,eAAe,EAAE,MAAM,CAAiB;IACxC,uBAAuB,EAAE,MAAM,CAAiB;IAChD,2EAA2E;IAC3E,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD,yEAAyE;IACzE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,kFAAkF;IAClF,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,qEAAqE;IACrE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IAQpD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAyC;gBAExD,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC;IAWrE,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED;;;;OAIG;IACH,IAAI,OAAO,IAAI,OAAO,CAErB;IAED;;;;OAIG;IACH,OAAO,IAAI,IAAI;IA2Bf;;;;OAIG;IACH,UAAU,CAAC,IAAI,GAAE,MAA2B,GAAG,IAAI;IAQnD,sGAAsG;IACtG,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;IAIxB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAW3B,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,MAAM,IAAI;IAK9D,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,IAAI;CA8G1D"}
1
+ {"version":3,"file":"socket.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/socket.svelte.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAC,MAAM,EAAC,MAAM,yBAAyB,CAAC;AAGpD,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,oBAAoB,CAAC;AAE5D,qDAAqD;AACrD,eAAO,MAAM,kBAAkB,OAAO,CAAC;AACvC,kCAAkC;AAClC,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAC5C,8DAA8D;AAC9D,eAAO,MAAM,2BAA2B,QAAQ,CAAC;AACjD,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C;;;;;;;;;GASG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,GAAG,QAAQ,CAAC;AAE9F,MAAM,MAAM,oBAAoB,GAAG,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;AACjE,MAAM,MAAM,kBAAkB,GAAG,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;AAExD,MAAM,WAAW,iCAAiC;IACjD,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,8BAA8B;IAC9C;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,GAAG,iCAAiC,GAAG,IAAI,CAAC;IAC/D,+CAA+C;IAC/C,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACpB;AAED;;;;;;;;;;GAUG;AACH,qBAAa,uBAAwB,YAAW,mBAAmB,EAAE,UAAU;;IAQ9E,EAAE,EAAE,SAAS,GAAG,IAAI,CAAoB;IACxC,MAAM,EAAE,YAAY,CAAyB;IAE7C,eAAe,EAAE,MAAM,CAAiB;IACxC,uBAAuB,EAAE,MAAM,CAAiB;IAChD,2EAA2E;IAC3E,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD,yEAAyE;IACzE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,kFAAkF;IAClF,eAAe,EAAE,MAAM,GAAG,IAAI,CAAoB;IAClD,qEAAqE;IACrE,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAoB;IACpD;;;;;;;;OAQG;IACH,eAAe,EAAE,KAAK,GAAG,IAAI,CAAoB;IASjD,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAyC;gBAExD,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,8BAAmC;IAWrE;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAAa,CAAC,SAAS,GAAE,OAAO,GAAG,iCAAiC,GAAG,IAAW,GAAG,IAAI;IA4CzF,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED;;;;OAIG;IACH,IAAI,OAAO,IAAI,OAAO,CAErB;IAED;;;;OAIG;IACH,OAAO,IAAI,IAAI;IA2Bf;;;;OAIG;IACH,UAAU,CAAC,IAAI,GAAE,MAA2B,GAAG,IAAI;IAQnD,sGAAsG;IACtG,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;IAIxB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO;IAa3B,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,MAAM,IAAI;IAK9D,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,IAAI;CAiH1D"}
@@ -53,7 +53,18 @@ export class FrontendWebsocketClient {
53
53
  last_close_code = $state.raw(null);
54
54
  /** Reason string from the most recent close event (may be empty). */
55
55
  last_close_reason = $state.raw(null);
56
+ /**
57
+ * The error thrown by the most recent attempted `send()`, or `null` if the
58
+ * most recent attempt succeeded or none has been attempted yet. Populated
59
+ * when the underlying `ws.send` throws (e.g., buffer full, serialization
60
+ * error); reset to `null` on the next successful send. Not touched when
61
+ * `send()` short-circuits because the socket is not connected — consult
62
+ * {@link connected} for that case. Wrappers surfacing per-message failure
63
+ * reasons can read this after a `false` return from `send()`.
64
+ */
65
+ last_send_error = $state.raw(null);
56
66
  #reconnect_timeout = null;
67
+ #reconnect_scheduled_at = null;
57
68
  #revoked = $state.raw(false);
58
69
  #message_handlers = new Set();
59
70
  #error_handlers = new Set();
@@ -68,6 +79,61 @@ export class FrontendWebsocketClient {
68
79
  this.#backoff_factor = config.factor ?? DEFAULT_BACKOFF_FACTOR;
69
80
  this.#log = options.log ?? null;
70
81
  }
82
+ /**
83
+ * Swap the auto-reconnect policy in place. Accepts the same shape as the
84
+ * constructor's `reconnect` option: `false` disables reconnect, `true` or
85
+ * `null`/omitted restores the defaults, or a config object customizes
86
+ * specific fields (missing fields fall back to defaults, not "keep
87
+ * current" — each call defines the whole policy atomically, same as the
88
+ * constructor).
89
+ *
90
+ * In-flight reconnect schedules are **monotonically shortened**: the
91
+ * effective total wait from arm-time never exceeds what the new policy
92
+ * prescribes. If the new target is already past the time already
93
+ * elapsed, the reconnect fires immediately (on the next tick). The wait
94
+ * is never extended.
95
+ *
96
+ * Turning reconnect off while a reconnect timer is pending cancels that
97
+ * timer and transitions status to `closed` (since the lie of
98
+ * `'reconnecting'` would be visible to UI indicators). Turning it back on
99
+ * does not synthesize a reconnect — wait for the next close.
100
+ */
101
+ set_reconnect(reconnect = null) {
102
+ const next_auto = reconnect !== false;
103
+ const config = typeof reconnect === 'object' && reconnect !== null ? reconnect : {};
104
+ this.#auto_reconnect = next_auto;
105
+ this.#reconnect_delay = config.delay ?? DEFAULT_RECONNECT_DELAY;
106
+ this.#reconnect_delay_max = config.delay_max ?? DEFAULT_RECONNECT_DELAY_MAX;
107
+ this.#backoff_factor = config.factor ?? DEFAULT_BACKOFF_FACTOR;
108
+ if (this.#reconnect_timeout === null)
109
+ return;
110
+ if (!next_auto) {
111
+ this.#cancel_reconnect();
112
+ this.status = 'closed';
113
+ this.reconnect_count = 0;
114
+ this.current_reconnect_delay = 0;
115
+ return;
116
+ }
117
+ // Auto-reconnect still on: monotonically shorten the pending wait if
118
+ // the new policy would produce a shorter total wait from arm-time.
119
+ // Never extends.
120
+ const scheduled_at = this.#reconnect_scheduled_at ?? Date.now();
121
+ const elapsed = Math.max(0, Date.now() - scheduled_at);
122
+ const remaining = Math.max(0, this.current_reconnect_delay - elapsed);
123
+ const new_target = Math.round(Math.min(this.#reconnect_delay_max, this.#reconnect_delay * this.#backoff_factor ** Math.max(0, this.reconnect_count - 1)));
124
+ const new_remaining = Math.max(0, new_target - elapsed);
125
+ if (new_remaining >= remaining)
126
+ return;
127
+ clearTimeout(this.#reconnect_timeout);
128
+ this.current_reconnect_delay = new_target;
129
+ // Keep #reconnect_scheduled_at at the original arm time so subsequent
130
+ // set_reconnect calls compute elapsed against a stable origin.
131
+ this.#reconnect_timeout = setTimeout(() => {
132
+ this.#reconnect_timeout = null;
133
+ this.#reconnect_scheduled_at = null;
134
+ this.connect();
135
+ }, new_remaining);
136
+ }
71
137
  get url() {
72
138
  return this.#url;
73
139
  }
@@ -132,10 +198,12 @@ export class FrontendWebsocketClient {
132
198
  return false;
133
199
  try {
134
200
  this.ws.send(JSON.stringify(data));
201
+ this.last_send_error = null;
135
202
  return true;
136
203
  }
137
204
  catch (error) {
138
205
  this.#log?.error('[socket] send failed:', error);
206
+ this.last_send_error = error instanceof Error ? error : new Error(String(error));
139
207
  return false;
140
208
  }
141
209
  }
@@ -179,8 +247,10 @@ export class FrontendWebsocketClient {
179
247
  this.reconnect_count++;
180
248
  this.current_reconnect_delay = Math.round(Math.min(this.#reconnect_delay_max, this.#reconnect_delay * this.#backoff_factor ** (this.reconnect_count - 1)));
181
249
  this.status = 'reconnecting';
250
+ this.#reconnect_scheduled_at = Date.now();
182
251
  this.#reconnect_timeout = setTimeout(() => {
183
252
  this.#reconnect_timeout = null;
253
+ this.#reconnect_scheduled_at = null;
184
254
  this.connect();
185
255
  }, this.current_reconnect_delay);
186
256
  }
@@ -189,6 +259,7 @@ export class FrontendWebsocketClient {
189
259
  clearTimeout(this.#reconnect_timeout);
190
260
  this.#reconnect_timeout = null;
191
261
  }
262
+ this.#reconnect_scheduled_at = null;
192
263
  }
193
264
  #handle_open = (_event) => {
194
265
  this.status = 'connected';
@@ -10,6 +10,19 @@ import type { FsReadDeps } from '../runtime/deps.js';
10
10
  /**
11
11
  * Parse a dotenv-format string into a record.
12
12
  *
13
+ * Values wrapped in `"..."` have `\\` → `\`, `\"` → `"`, `\n` → newline,
14
+ * and `\r` → carriage-return decoded (symmetric with the writer in
15
+ * `update_env_variable`). Values wrapped in `'...'` are taken literally —
16
+ * no escape processing. Unquoted values are unchanged.
17
+ *
18
+ * Inline comments are stripped after a closing quote (e.g. `KEY="v" # c` → `v`)
19
+ * and after whitespace on unquoted values (e.g. `KEY=v # c` → `v`). Unquoted
20
+ * values keep `#` literal when no whitespace precedes it so URL fragments
21
+ * like `KEY=https://x.com#frag` round-trip unchanged.
22
+ *
23
+ * Trailing whitespace on unquoted values is lost (the raw value is trimmed);
24
+ * wrap the value in `"..."` or `'...'` to preserve surrounding spacing.
25
+ *
13
26
  * @param content - dotenv file content
14
27
  * @returns parsed key-value pairs
15
28
  */
@@ -17,6 +30,10 @@ export declare const parse_dotenv: (content: string) => Record<string, string>;
17
30
  /**
18
31
  * Load and parse an env file.
19
32
  *
33
+ * Returns null only when the file does not exist. Other read errors
34
+ * (permission denied, I/O failure, etc.) are re-thrown so callers can
35
+ * distinguish "no file" from "couldn't read".
36
+ *
20
37
  * @param runtime - runtime with `read_text_file` capability
21
38
  * @param path - path to env file
22
39
  * @returns parsed env record, or null if file doesn't exist
@@ -1 +1 @@
1
- {"version":3,"file":"dotenv.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/env/dotenv.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,oBAAoB,CAAC;AAEnD;;;;;GAKG;AACH,eAAO,MAAM,YAAY,GAAI,SAAS,MAAM,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAqBnE,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,GACzB,SAAS,IAAI,CAAC,UAAU,EAAE,gBAAgB,CAAC,EAC3C,MAAM,MAAM,KACV,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAOvC,CAAC"}
1
+ {"version":3,"file":"dotenv.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/env/dotenv.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,oBAAoB,CAAC;AAEnD;;;;;;;;;;;;;;;;;;GAkBG;AAGH,eAAO,MAAM,YAAY,GAAI,SAAS,MAAM,KAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAkCnE,CAAC;AAiEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,aAAa,GACzB,SAAS,IAAI,CAAC,UAAU,EAAE,gBAAgB,CAAC,EAC3C,MAAM,MAAM,KACV,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CASvC,CAAC"}
@@ -9,14 +9,28 @@
9
9
  /**
10
10
  * Parse a dotenv-format string into a record.
11
11
  *
12
+ * Values wrapped in `"..."` have `\\` → `\`, `\"` → `"`, `\n` → newline,
13
+ * and `\r` → carriage-return decoded (symmetric with the writer in
14
+ * `update_env_variable`). Values wrapped in `'...'` are taken literally —
15
+ * no escape processing. Unquoted values are unchanged.
16
+ *
17
+ * Inline comments are stripped after a closing quote (e.g. `KEY="v" # c` → `v`)
18
+ * and after whitespace on unquoted values (e.g. `KEY=v # c` → `v`). Unquoted
19
+ * values keep `#` literal when no whitespace precedes it so URL fragments
20
+ * like `KEY=https://x.com#frag` round-trip unchanged.
21
+ *
22
+ * Trailing whitespace on unquoted values is lost (the raw value is trimmed);
23
+ * wrap the value in `"..."` or `'...'` to preserve surrounding spacing.
24
+ *
12
25
  * @param content - dotenv file content
13
26
  * @returns parsed key-value pairs
14
27
  */
28
+ // Line tokenization (trim, skip empties/comments, split on first `=`) is
29
+ // mirrored in `update_env_variable.ts`'s `find_last_key_line_index`.
15
30
  export const parse_dotenv = (content) => {
16
31
  const result = {};
17
32
  for (const line of content.split('\n')) {
18
33
  const trimmed = line.trim();
19
- // skip empty lines and comments
20
34
  if (!trimmed || trimmed.startsWith('#'))
21
35
  continue;
22
36
  const eq_index = trimmed.indexOf('=');
@@ -24,19 +38,101 @@ export const parse_dotenv = (content) => {
24
38
  continue;
25
39
  const key = trimmed.slice(0, eq_index).trim();
26
40
  let value = trimmed.slice(eq_index + 1).trim();
27
- // remove surrounding quotes if present (need at least 2 chars for open+close)
28
- if (value.length >= 2 &&
29
- ((value.startsWith('"') && value.endsWith('"')) ||
30
- (value.startsWith("'") && value.endsWith("'")))) {
31
- value = value.slice(1, -1);
41
+ if (value.startsWith('"')) {
42
+ const close = find_closing_double_quote(value);
43
+ if (close !== -1 && is_comment_or_empty_after(value, close)) {
44
+ value = unescape_double_quoted(value.slice(1, close));
45
+ }
46
+ }
47
+ else if (value.startsWith("'")) {
48
+ const close = value.indexOf("'", 1);
49
+ if (close !== -1 && is_comment_or_empty_after(value, close)) {
50
+ value = value.slice(1, close);
51
+ }
52
+ }
53
+ else if (value.startsWith('#')) {
54
+ // Leading `#` on an unquoted value means the value is empty and the
55
+ // rest is a comment (`KEY=#c` or `KEY= # c` → `''`).
56
+ value = '';
57
+ }
58
+ else {
59
+ // Unquoted: strip trailing `\s+#...` so `KEY=v # c` → `v` while
60
+ // `KEY=https://x.com#frag` (no whitespace before `#`) stays literal.
61
+ const comment_idx = value.search(/\s+#/);
62
+ if (comment_idx !== -1)
63
+ value = value.slice(0, comment_idx).trimEnd();
32
64
  }
33
65
  result[key] = value;
34
66
  }
35
67
  return result;
36
68
  };
69
+ /**
70
+ * Find the index of the unescaped closing `"` in a `"..."`-wrapped value.
71
+ * Returns -1 if no closing quote is found. Caller guarantees `value[0] === '"'`.
72
+ */
73
+ const find_closing_double_quote = (value) => {
74
+ let i = 1;
75
+ while (i < value.length) {
76
+ const ch = value[i];
77
+ if (ch === '\\' && i + 1 < value.length) {
78
+ i += 2;
79
+ continue;
80
+ }
81
+ if (ch === '"')
82
+ return i;
83
+ i++;
84
+ }
85
+ return -1;
86
+ };
87
+ /**
88
+ * Returns true if everything after `pos` (the closing quote) is whitespace
89
+ * or a `# ...` inline comment. Used to decide whether the line is a clean
90
+ * `KEY="value" [# comment]` assignment vs. something we should leave raw.
91
+ */
92
+ const is_comment_or_empty_after = (value, pos) => /^\s*(#.*)?$/.test(value.slice(pos + 1));
93
+ /**
94
+ * Single-pass unescape for the inside of a `"..."` dotenv value.
95
+ *
96
+ * Recognizes `\\` → `\`, `\"` → `"`, `\n` → newline, `\r` → CR. Any other
97
+ * backslash is emitted literally. A chained `.replace(/\\\\/g, '\\').replace(...)`
98
+ * would mishandle real-backslash followed by escaped-quote (`\\"` on disk
99
+ * = `[\][\]["]` would collapse to `"` instead of `\"`).
100
+ */
101
+ const unescape_double_quoted = (s) => {
102
+ let result = '';
103
+ let i = 0;
104
+ while (i < s.length) {
105
+ const ch = s[i];
106
+ if (ch === '\\' && i + 1 < s.length) {
107
+ const next = s[i + 1];
108
+ if (next === '\\' || next === '"') {
109
+ result += next;
110
+ i += 2;
111
+ continue;
112
+ }
113
+ if (next === 'n') {
114
+ result += '\n';
115
+ i += 2;
116
+ continue;
117
+ }
118
+ if (next === 'r') {
119
+ result += '\r';
120
+ i += 2;
121
+ continue;
122
+ }
123
+ }
124
+ result += ch;
125
+ i++;
126
+ }
127
+ return result;
128
+ };
37
129
  /**
38
130
  * Load and parse an env file.
39
131
  *
132
+ * Returns null only when the file does not exist. Other read errors
133
+ * (permission denied, I/O failure, etc.) are re-thrown so callers can
134
+ * distinguish "no file" from "couldn't read".
135
+ *
40
136
  * @param runtime - runtime with `read_text_file` capability
41
137
  * @param path - path to env file
42
138
  * @returns parsed env record, or null if file doesn't exist
@@ -46,7 +142,10 @@ export const load_env_file = async (runtime, path) => {
46
142
  const content = await runtime.read_text_file(path);
47
143
  return parse_dotenv(content);
48
144
  }
49
- catch {
50
- return null;
145
+ catch (error) {
146
+ // Node (`ENOENT`) and Deno (`Deno.errors.NotFound`) — handle both.
147
+ if (error?.code === 'ENOENT' || error?.name === 'NotFound')
148
+ return null;
149
+ throw error;
51
150
  }
52
151
  };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Write updates to `.env` files while preserving formatting.
3
+ *
4
+ * @module
5
+ */
6
+ /**
7
+ * Options for updating environment variables in a .env file.
8
+ */
9
+ export interface UpdateEnvVariableOptions {
10
+ /** Path to the .env file. */
11
+ env_file_path: string;
12
+ /** Function to read file contents (defaults to `node:fs/promises` `readFile`). */
13
+ read_file?: (path: string, encoding: string) => Promise<string>;
14
+ /** Function to write file contents (defaults to `node:fs/promises` `writeFile`). */
15
+ write_file?: (path: string, content: string, encoding: string) => Promise<void>;
16
+ }
17
+ /**
18
+ * Updates or adds an environment variable in the .env file.
19
+ * Preserves existing formatting, comments, and other variables.
20
+ *
21
+ * Behavior:
22
+ * - **Duplicate keys**: updates the LAST occurrence (matches dotenv behavior)
23
+ * - **Inline comments**: preserved after the value (e.g., `KEY=value # comment`)
24
+ * - **Quote style**: preserved from original (quoted/unquoted)
25
+ *
26
+ * @warning Not atomic; not safe for concurrent writers. Reads the file, mutates
27
+ * in memory, then writes it back — a crash or concurrent write can corrupt
28
+ * the file. Acceptable for single-user, infrequent edits; revisit if a
29
+ * concurrent-writer consumer emerges.
30
+ *
31
+ * @param key - the environment variable name (e.g., `'SOME_CONFIGURATION_KEY'`)
32
+ * @param value - the new value for the environment variable
33
+ * @param options - file path and optional read/write overrides
34
+ */
35
+ export declare const update_env_variable: (key: string, value: string, options: UpdateEnvVariableOptions) => Promise<void>;
36
+ //# sourceMappingURL=update_env_variable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update_env_variable.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/env/update_env_variable.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAKH;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACxC,6BAA6B;IAC7B,aAAa,EAAE,MAAM,CAAC;IACtB,kFAAkF;IAClF,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAChE,oFAAoF;IACpF,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAChF;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,mBAAmB,GAC/B,KAAK,MAAM,EACX,OAAO,MAAM,EACb,SAAS,wBAAwB,KAC/B,OAAO,CAAC,IAAI,CAmDd,CAAC"}
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Write updates to `.env` files while preserving formatting.
3
+ *
4
+ * @module
5
+ */
6
+ import { readFile, writeFile } from 'node:fs/promises';
7
+ import { resolve } from 'node:path';
8
+ /**
9
+ * Updates or adds an environment variable in the .env file.
10
+ * Preserves existing formatting, comments, and other variables.
11
+ *
12
+ * Behavior:
13
+ * - **Duplicate keys**: updates the LAST occurrence (matches dotenv behavior)
14
+ * - **Inline comments**: preserved after the value (e.g., `KEY=value # comment`)
15
+ * - **Quote style**: preserved from original (quoted/unquoted)
16
+ *
17
+ * @warning Not atomic; not safe for concurrent writers. Reads the file, mutates
18
+ * in memory, then writes it back — a crash or concurrent write can corrupt
19
+ * the file. Acceptable for single-user, infrequent edits; revisit if a
20
+ * concurrent-writer consumer emerges.
21
+ *
22
+ * @param key - the environment variable name (e.g., `'SOME_CONFIGURATION_KEY'`)
23
+ * @param value - the new value for the environment variable
24
+ * @param options - file path and optional read/write overrides
25
+ */
26
+ export const update_env_variable = async (key, value, options) => {
27
+ const { env_file_path, read_file = readFile, write_file = writeFile } = options;
28
+ const file_path = resolve(env_file_path);
29
+ let content = '';
30
+ try {
31
+ content = await read_file(file_path, 'utf-8');
32
+ }
33
+ catch (error) {
34
+ if (error?.code !== 'ENOENT') {
35
+ throw error;
36
+ }
37
+ }
38
+ // TODO CRLF: split on `/\r?\n/` + remember per-line delimiter (pinned by edge_cases.test.ts)
39
+ // Preserve trailing-newline state: `split('\n')` on `'a\n'` yields `['a', '']`
40
+ // — drop that trailing empty so appends don't insert a blank line. Re-add
41
+ // the trailing `\n` at the end if the original had one.
42
+ const has_trailing_newline = content.endsWith('\n');
43
+ const lines = content.split('\n');
44
+ if (has_trailing_newline)
45
+ lines.pop();
46
+ // Find the LAST occurrence of the key (matches dotenv "last wins" behavior)
47
+ const last_match_idx = find_last_key_line_index(lines, key);
48
+ const updated_lines = lines.map((line, idx) => {
49
+ if (idx === last_match_idx) {
50
+ const equals_pos = line.indexOf('=');
51
+ const value_part = line.substring(equals_pos + 1);
52
+ const inline_comment = extract_inline_comment(value_part);
53
+ const trimmed_value = value_part.trim();
54
+ const has_quotes = is_quoted_value(trimmed_value);
55
+ return has_quotes
56
+ ? `${key}=${quote_value(value)}${inline_comment}`
57
+ : `${key}=${value}${inline_comment}`;
58
+ }
59
+ return line;
60
+ });
61
+ if (last_match_idx === -1) {
62
+ if (content === '') {
63
+ await write_file(file_path, `${key}=${quote_value(value)}\n`, 'utf-8');
64
+ return;
65
+ }
66
+ updated_lines.push(`${key}=${quote_value(value)}`);
67
+ }
68
+ const updated_content = updated_lines.join('\n') + (has_trailing_newline ? '\n' : '');
69
+ await write_file(file_path, updated_content, 'utf-8');
70
+ };
71
+ // Keep this tokenization aligned with `parse_dotenv` in `./dotenv.ts`:
72
+ // trim, skip empties/comments, split on the first `=`.
73
+ const find_last_key_line_index = (lines, key) => {
74
+ if (!key)
75
+ return -1;
76
+ let last_match_idx = -1;
77
+ lines.forEach((line, idx) => {
78
+ const trimmed = line.trim();
79
+ if (!trimmed || trimmed.startsWith('#'))
80
+ return;
81
+ const eq_index = trimmed.indexOf('=');
82
+ if (eq_index === -1)
83
+ return;
84
+ if (trimmed.slice(0, eq_index).trim() === key)
85
+ last_match_idx = idx;
86
+ });
87
+ return last_match_idx;
88
+ };
89
+ const extract_inline_comment = (value_part) => {
90
+ const trimmed_value = value_part.trim();
91
+ if (is_quoted_value(trimmed_value)) {
92
+ const quote_char = trimmed_value[0];
93
+ let closing_quote_idx = trimmed_value.indexOf(quote_char, 1);
94
+ while (closing_quote_idx > 0 && is_quote_escaped(trimmed_value, closing_quote_idx)) {
95
+ closing_quote_idx = trimmed_value.indexOf(quote_char, closing_quote_idx + 1);
96
+ }
97
+ if (closing_quote_idx !== -1) {
98
+ const after_quote = trimmed_value.substring(closing_quote_idx + 1);
99
+ const comment_match = /(\s*#.*)/.exec(after_quote);
100
+ const captured_comment = comment_match?.[1];
101
+ if (captured_comment) {
102
+ return captured_comment;
103
+ }
104
+ }
105
+ }
106
+ else {
107
+ // Leading `#` (e.g. `KEY=#c`) — whole trailing text is a comment. Emit
108
+ // with a space separator so it can't merge with the new value and the
109
+ // parser can round-trip the new value cleanly.
110
+ const trimmed_vp = value_part.trimStart();
111
+ if (trimmed_vp.startsWith('#'))
112
+ return ' ' + trimmed_vp;
113
+ // Require whitespace before `#` — symmetric with `parse_dotenv`, which
114
+ // strips `\s+#...` from unquoted values. URL fragments (no whitespace
115
+ // before `#`) are literal on both sides.
116
+ const comment_match = /(\s+#.*)/.exec(value_part);
117
+ const captured_comment = comment_match?.[1];
118
+ if (captured_comment) {
119
+ return captured_comment;
120
+ }
121
+ }
122
+ return '';
123
+ };
124
+ /**
125
+ * Checks if a quote character at a specific position is escaped by counting
126
+ * consecutive backslashes before it. An odd count means the quote is escaped.
127
+ */
128
+ const is_quote_escaped = (str, quote_pos) => {
129
+ let backslash_count = 0;
130
+ let pos = quote_pos - 1;
131
+ while (pos >= 0 && str[pos] === '\\') {
132
+ backslash_count++;
133
+ pos--;
134
+ }
135
+ return backslash_count % 2 === 1;
136
+ };
137
+ const QUOTE_CHARS = ['"', "'"];
138
+ const is_quoted_value = (value) => QUOTE_CHARS.some((char) => value.startsWith(char));
139
+ /**
140
+ * Wraps a value for safe insertion into a double- or single-quoted dotenv line.
141
+ *
142
+ * - Uses `'...'` when the value contains `"` but no `'`, no `\n`, and no `\r`
143
+ * (single-quoted dotenv values are taken literally — no escape processing,
144
+ * and a literal newline would break the line into two).
145
+ * - Otherwise uses `"..."` with `\` → `\\`, `"` → `\"`, newline → `\n`, and
146
+ * CR → `\r` so the line stays a single parseable assignment and round-trips
147
+ * through `parse_dotenv` losslessly.
148
+ */
149
+ const quote_value = (value) => {
150
+ if (value.includes('"') && !value.includes("'") && !has_newline_chars(value)) {
151
+ return `'${value}'`;
152
+ }
153
+ return `"${escape_for_double_quoted(value)}"`;
154
+ };
155
+ const has_newline_chars = (value) => value.includes('\n') || value.includes('\r');
156
+ /**
157
+ * Order matters: backslashes must be escaped first so the introduced
158
+ * backslashes from later replacements aren't re-escaped.
159
+ */
160
+ const escape_for_double_quoted = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
@@ -0,0 +1,21 @@
1
+ /**
2
+ * In-memory file system mock for tests that use dependency-injected
3
+ * `read_file` and `write_file` callbacks. Avoids module-level mocking.
4
+ *
5
+ * @module
6
+ */
7
+ export interface MockFs {
8
+ read_file: (path: string, encoding: string) => Promise<string>;
9
+ write_file: (path: string, content: string, encoding: string) => Promise<void>;
10
+ get_file: (path: string) => string | undefined;
11
+ }
12
+ /**
13
+ * Creates an in-memory file system for tests.
14
+ *
15
+ * `read_file` throws an `ENOENT`-tagged error for missing paths so callers
16
+ * can exercise the same "file doesn't exist" code path as `node:fs`.
17
+ *
18
+ * @param initial_files - starting contents keyed by absolute path
19
+ */
20
+ export declare const create_mock_fs: (initial_files?: Record<string, string>) => MockFs;
21
+ //# sourceMappingURL=mock_fs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mock_fs.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/testing/mock_fs.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,WAAW,MAAM;IACtB,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/D,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/E,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;CAC/C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,gBAAe,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,KAAG,MAuB3E,CAAC"}
@@ -0,0 +1,37 @@
1
+ /**
2
+ * In-memory file system mock for tests that use dependency-injected
3
+ * `read_file` and `write_file` callbacks. Avoids module-level mocking.
4
+ *
5
+ * @module
6
+ */
7
+ /**
8
+ * Creates an in-memory file system for tests.
9
+ *
10
+ * `read_file` throws an `ENOENT`-tagged error for missing paths so callers
11
+ * can exercise the same "file doesn't exist" code path as `node:fs`.
12
+ *
13
+ * @param initial_files - starting contents keyed by absolute path
14
+ */
15
+ export const create_mock_fs = (initial_files = {}) => {
16
+ const files = { ...initial_files };
17
+ return {
18
+ // eslint-disable-next-line @typescript-eslint/require-await
19
+ read_file: async (path, _encoding) => {
20
+ if (!(path in files)) {
21
+ const error = new Error(`ENOENT: no such file or directory, open '${path}'`);
22
+ error.code = 'ENOENT';
23
+ throw error;
24
+ }
25
+ const file_content = files[path];
26
+ if (file_content === undefined) {
27
+ throw new Error(`File at ${path} exists in record but has undefined content`);
28
+ }
29
+ return file_content;
30
+ },
31
+ // eslint-disable-next-line @typescript-eslint/require-await
32
+ write_file: async (path, content, _encoding) => {
33
+ files[path] = content;
34
+ },
35
+ get_file: (path) => files[path],
36
+ };
37
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzdev/fuz_app",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",