@fuzdev/fuz_app 0.17.1 → 0.19.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 {
@@ -96,8 +96,8 @@ export interface RegisterActionWsResult {
96
96
  * - Notifications (method + no id) are silently dropped per JSON-RPC spec.
97
97
  * - Per-action auth: `public` / `authenticated` pass through (upgrade auth
98
98
  * already verified identity); `keeper` requires `daemon_token` credential
99
- * type *and* the keeper role; role-based `{role}` is currently rejected as
100
- * not-yet-supported.
99
+ * type *and* the keeper role; role-based `{role}` requires the named role
100
+ * via `has_role`, matching the HTTP path in `action_rpc.ts`.
101
101
  * - DEV mode validates handler output against the spec's `output` schema and
102
102
  * warns on mismatches.
103
103
  *
@@ -42,8 +42,8 @@ import { BackendWebsocketTransport } from './transports_ws_backend.js';
42
42
  * - Notifications (method + no id) are silently dropped per JSON-RPC spec.
43
43
  * - Per-action auth: `public` / `authenticated` pass through (upgrade auth
44
44
  * already verified identity); `keeper` requires `daemon_token` credential
45
- * type *and* the keeper role; role-based `{role}` is currently rejected as
46
- * not-yet-supported.
45
+ * type *and* the keeper role; role-based `{role}` requires the named role
46
+ * via `has_role`, matching the HTTP path in `action_rpc.ts`.
47
47
  * - DEV mode validates handler output against the spec's `output` schema and
48
48
  * warns on mismatches.
49
49
  *
@@ -118,8 +118,10 @@ export const register_action_ws = (options) => {
118
118
  }
119
119
  }
120
120
  else if (typeof auth === 'object' && auth !== null) {
121
- ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.internal_error('role-based action auth is not yet supported on WebSocket'))));
122
- return;
121
+ if (!has_role(request_context, auth.role)) {
122
+ ws.send(JSON.stringify(create_jsonrpc_error_response(id, jsonrpc_error_messages.forbidden(`requires role: ${auth.role}`))));
123
+ return;
124
+ }
123
125
  }
124
126
  // Look up handler — method is validated against spec_by_method above.
125
127
  const handler = handlers[method];
@@ -80,6 +80,26 @@ export declare class FrontendWebsocketClient implements WebsocketConnection, Dis
80
80
  last_close_reason: string | null;
81
81
  readonly connected: boolean;
82
82
  constructor(url: string, options?: FrontendWebsocketClientOptions);
83
+ /**
84
+ * Swap the auto-reconnect policy in place. Accepts the same shape as the
85
+ * constructor's `reconnect` option: `false` disables reconnect, `true` or
86
+ * `null`/omitted restores the defaults, or a config object customizes
87
+ * specific fields (missing fields fall back to defaults, not "keep
88
+ * current" — each call defines the whole policy atomically, same as the
89
+ * constructor).
90
+ *
91
+ * In-flight reconnect schedules are **monotonically shortened**: the
92
+ * effective total wait from arm-time never exceeds what the new policy
93
+ * prescribes. If the new target is already past the time already
94
+ * elapsed, the reconnect fires immediately (on the next tick). The wait
95
+ * is never extended.
96
+ *
97
+ * Turning reconnect off while a reconnect timer is pending cancels that
98
+ * timer and transitions status to `closed` (since the lie of
99
+ * `'reconnecting'` would be visible to UI indicators). Turning it back on
100
+ * does not synthesize a reconnect — wait for the next close.
101
+ */
102
+ set_reconnect(reconnect?: boolean | FrontendWebsocketReconnectOptions | null): void;
83
103
  get url(): string;
84
104
  /**
85
105
  * 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;IASpD,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;IAW3B,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,MAAM,IAAI;IAK9D,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,MAAM,IAAI;CAiH1D"}
@@ -54,6 +54,7 @@ export class FrontendWebsocketClient {
54
54
  /** Reason string from the most recent close event (may be empty). */
55
55
  last_close_reason = $state.raw(null);
56
56
  #reconnect_timeout = null;
57
+ #reconnect_scheduled_at = null;
57
58
  #revoked = $state.raw(false);
58
59
  #message_handlers = new Set();
59
60
  #error_handlers = new Set();
@@ -68,6 +69,61 @@ export class FrontendWebsocketClient {
68
69
  this.#backoff_factor = config.factor ?? DEFAULT_BACKOFF_FACTOR;
69
70
  this.#log = options.log ?? null;
70
71
  }
72
+ /**
73
+ * Swap the auto-reconnect policy in place. Accepts the same shape as the
74
+ * constructor's `reconnect` option: `false` disables reconnect, `true` or
75
+ * `null`/omitted restores the defaults, or a config object customizes
76
+ * specific fields (missing fields fall back to defaults, not "keep
77
+ * current" — each call defines the whole policy atomically, same as the
78
+ * constructor).
79
+ *
80
+ * In-flight reconnect schedules are **monotonically shortened**: the
81
+ * effective total wait from arm-time never exceeds what the new policy
82
+ * prescribes. If the new target is already past the time already
83
+ * elapsed, the reconnect fires immediately (on the next tick). The wait
84
+ * is never extended.
85
+ *
86
+ * Turning reconnect off while a reconnect timer is pending cancels that
87
+ * timer and transitions status to `closed` (since the lie of
88
+ * `'reconnecting'` would be visible to UI indicators). Turning it back on
89
+ * does not synthesize a reconnect — wait for the next close.
90
+ */
91
+ set_reconnect(reconnect = null) {
92
+ const next_auto = reconnect !== false;
93
+ const config = typeof reconnect === 'object' && reconnect !== null ? reconnect : {};
94
+ this.#auto_reconnect = next_auto;
95
+ this.#reconnect_delay = config.delay ?? DEFAULT_RECONNECT_DELAY;
96
+ this.#reconnect_delay_max = config.delay_max ?? DEFAULT_RECONNECT_DELAY_MAX;
97
+ this.#backoff_factor = config.factor ?? DEFAULT_BACKOFF_FACTOR;
98
+ if (this.#reconnect_timeout === null)
99
+ return;
100
+ if (!next_auto) {
101
+ this.#cancel_reconnect();
102
+ this.status = 'closed';
103
+ this.reconnect_count = 0;
104
+ this.current_reconnect_delay = 0;
105
+ return;
106
+ }
107
+ // Auto-reconnect still on: monotonically shorten the pending wait if
108
+ // the new policy would produce a shorter total wait from arm-time.
109
+ // Never extends.
110
+ const scheduled_at = this.#reconnect_scheduled_at ?? Date.now();
111
+ const elapsed = Math.max(0, Date.now() - scheduled_at);
112
+ const remaining = Math.max(0, this.current_reconnect_delay - elapsed);
113
+ const new_target = Math.round(Math.min(this.#reconnect_delay_max, this.#reconnect_delay * this.#backoff_factor ** Math.max(0, this.reconnect_count - 1)));
114
+ const new_remaining = Math.max(0, new_target - elapsed);
115
+ if (new_remaining >= remaining)
116
+ return;
117
+ clearTimeout(this.#reconnect_timeout);
118
+ this.current_reconnect_delay = new_target;
119
+ // Keep #reconnect_scheduled_at at the original arm time so subsequent
120
+ // set_reconnect calls compute elapsed against a stable origin.
121
+ this.#reconnect_timeout = setTimeout(() => {
122
+ this.#reconnect_timeout = null;
123
+ this.#reconnect_scheduled_at = null;
124
+ this.connect();
125
+ }, new_remaining);
126
+ }
71
127
  get url() {
72
128
  return this.#url;
73
129
  }
@@ -179,8 +235,10 @@ export class FrontendWebsocketClient {
179
235
  this.reconnect_count++;
180
236
  this.current_reconnect_delay = Math.round(Math.min(this.#reconnect_delay_max, this.#reconnect_delay * this.#backoff_factor ** (this.reconnect_count - 1)));
181
237
  this.status = 'reconnecting';
238
+ this.#reconnect_scheduled_at = Date.now();
182
239
  this.#reconnect_timeout = setTimeout(() => {
183
240
  this.#reconnect_timeout = null;
241
+ this.#reconnect_scheduled_at = null;
184
242
  this.connect();
185
243
  }, this.current_reconnect_delay);
186
244
  }
@@ -189,6 +247,7 @@ export class FrontendWebsocketClient {
189
247
  clearTimeout(this.#reconnect_timeout);
190
248
  this.#reconnect_timeout = null;
191
249
  }
250
+ this.#reconnect_scheduled_at = null;
192
251
  }
193
252
  #handle_open = (_event) => {
194
253
  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.17.1",
3
+ "version": "0.19.0",
4
4
  "description": "fullstack app library",
5
5
  "glyph": "🗝",
6
6
  "logo": "logo.svg",