@ninedeploy/sdk 0.10.6 → 0.16.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.
Files changed (129) hide show
  1. package/LICENSE +21 -21
  2. package/dist/apiPath.d.ts +5 -0
  3. package/dist/apiPath.d.ts.map +1 -0
  4. package/dist/apiPath.js +18 -0
  5. package/dist/apiPath.js.map +1 -0
  6. package/dist/client.d.ts +29 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +51 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/databaseImport.d.ts +138 -0
  11. package/dist/databaseImport.d.ts.map +1 -0
  12. package/dist/databaseImport.js +126 -0
  13. package/dist/databaseImport.js.map +1 -0
  14. package/dist/errors.js +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/index.d.ts +12 -1973
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +9 -641
  19. package/dist/index.js.map +1 -1
  20. package/dist/manifest.d.ts.map +1 -1
  21. package/dist/manifest.js +25 -21
  22. package/dist/manifest.js.map +1 -1
  23. package/dist/resources/auth.d.ts +282 -0
  24. package/dist/resources/auth.d.ts.map +1 -0
  25. package/dist/resources/auth.js +111 -0
  26. package/dist/resources/auth.js.map +1 -0
  27. package/dist/resources/databases.d.ts +178 -0
  28. package/dist/resources/databases.d.ts.map +1 -0
  29. package/dist/resources/databases.js +82 -0
  30. package/dist/resources/databases.js.map +1 -0
  31. package/dist/resources/deploys.d.ts +125 -0
  32. package/dist/resources/deploys.d.ts.map +1 -0
  33. package/dist/resources/deploys.js +46 -0
  34. package/dist/resources/deploys.js.map +1 -0
  35. package/dist/resources/domains.d.ts +101 -0
  36. package/dist/resources/domains.d.ts.map +1 -0
  37. package/dist/resources/domains.js +28 -0
  38. package/dist/resources/domains.js.map +1 -0
  39. package/dist/resources/env.d.ts +55 -0
  40. package/dist/resources/env.d.ts.map +1 -0
  41. package/dist/resources/env.js +33 -0
  42. package/dist/resources/env.js.map +1 -0
  43. package/dist/resources/logs.d.ts +27 -0
  44. package/dist/resources/logs.d.ts.map +1 -0
  45. package/dist/resources/logs.js +18 -0
  46. package/dist/resources/logs.js.map +1 -0
  47. package/dist/resources/network.d.ts +134 -0
  48. package/dist/resources/network.d.ts.map +1 -0
  49. package/dist/resources/network.js +33 -0
  50. package/dist/resources/network.js.map +1 -0
  51. package/dist/resources/plugins.d.ts +42 -0
  52. package/dist/resources/plugins.d.ts.map +1 -0
  53. package/dist/resources/plugins.js +22 -0
  54. package/dist/resources/plugins.js.map +1 -0
  55. package/dist/resources/servers.d.ts +130 -0
  56. package/dist/resources/servers.d.ts.map +1 -0
  57. package/dist/resources/servers.js +37 -0
  58. package/dist/resources/servers.js.map +1 -0
  59. package/dist/resources/services.d.ts +185 -0
  60. package/dist/resources/services.d.ts.map +1 -0
  61. package/dist/resources/services.js +83 -0
  62. package/dist/resources/services.js.map +1 -0
  63. package/dist/resources/settings.d.ts +312 -0
  64. package/dist/resources/settings.d.ts.map +1 -0
  65. package/dist/resources/settings.js +85 -0
  66. package/dist/resources/settings.js.map +1 -0
  67. package/dist/resources/sources.d.ts +87 -0
  68. package/dist/resources/sources.d.ts.map +1 -0
  69. package/dist/resources/sources.js +39 -0
  70. package/dist/resources/sources.js.map +1 -0
  71. package/dist/resources/system.d.ts +240 -0
  72. package/dist/resources/system.d.ts.map +1 -0
  73. package/dist/resources/system.js +86 -0
  74. package/dist/resources/system.js.map +1 -0
  75. package/dist/resources/templates.d.ts +36 -0
  76. package/dist/resources/templates.d.ts.map +1 -0
  77. package/dist/resources/templates.js +17 -0
  78. package/dist/resources/templates.js.map +1 -0
  79. package/dist/resources/terminals.d.ts +60 -0
  80. package/dist/resources/terminals.d.ts.map +1 -0
  81. package/dist/resources/terminals.js +35 -0
  82. package/dist/resources/terminals.js.map +1 -0
  83. package/dist/resources/traefik.d.ts +84 -0
  84. package/dist/resources/traefik.d.ts.map +1 -0
  85. package/dist/resources/traefik.js +35 -0
  86. package/dist/resources/traefik.js.map +1 -0
  87. package/dist/resources/volumes.d.ts +114 -0
  88. package/dist/resources/volumes.d.ts.map +1 -0
  89. package/dist/resources/volumes.js +47 -0
  90. package/dist/resources/volumes.js.map +1 -0
  91. package/dist/terminal.d.ts +98 -0
  92. package/dist/terminal.d.ts.map +1 -0
  93. package/dist/terminal.js +180 -0
  94. package/dist/terminal.js.map +1 -0
  95. package/dist/transport.d.ts +61 -0
  96. package/dist/transport.d.ts.map +1 -0
  97. package/dist/transport.js +90 -0
  98. package/dist/transport.js.map +1 -0
  99. package/dist/types.d.ts +539 -0
  100. package/dist/types.d.ts.map +1 -0
  101. package/dist/types.js +5 -0
  102. package/dist/types.js.map +1 -0
  103. package/package.json +5 -4
  104. package/src/apiPath.ts +23 -0
  105. package/src/client.ts +70 -0
  106. package/src/databaseImport.ts +174 -0
  107. package/src/errors.ts +27 -0
  108. package/src/index.ts +118 -0
  109. package/src/manifest.ts +414 -0
  110. package/src/resources/auth.ts +357 -0
  111. package/src/resources/databases.ts +227 -0
  112. package/src/resources/deploys.ts +161 -0
  113. package/src/resources/domains.ts +142 -0
  114. package/src/resources/env.ts +84 -0
  115. package/src/resources/logs.ts +48 -0
  116. package/src/resources/network.ts +126 -0
  117. package/src/resources/plugins.ts +52 -0
  118. package/src/resources/servers.ts +141 -0
  119. package/src/resources/services.ts +250 -0
  120. package/src/resources/settings.ts +275 -0
  121. package/src/resources/sources.ts +131 -0
  122. package/src/resources/system.ts +281 -0
  123. package/src/resources/templates.ts +55 -0
  124. package/src/resources/terminals.ts +112 -0
  125. package/src/resources/traefik.ts +116 -0
  126. package/src/resources/volumes.ts +138 -0
  127. package/src/terminal.ts +254 -0
  128. package/src/transport.ts +154 -0
  129. package/src/types.ts +588 -0
@@ -0,0 +1,138 @@
1
+ // Client surface: volumes, containers, serviceVolumes.
2
+ // Split out of the former single-file SDK entry (src/index.ts); the public
3
+ // surface is re-exported unchanged from there.
4
+ import type {
5
+ CreateServiceVolumeAttachmentInput,
6
+ ServiceVolumeAttachment,
7
+ UpdateServiceVolumeAttachmentInput,
8
+ VolumeFileEntry,
9
+ VolumeFileWriteInput,
10
+ VolumePathCreateInput,
11
+ VolumeCreateInput,
12
+ } from '@ninedeploy/schemas';
13
+ import { apiGetUrl } from '../apiPath.js';
14
+ import type { VolumeListEntry, VolumeCreateResult, ContainerInspectData, ContainerComposeData } from '../types.js';
15
+ import type { Transport } from '../transport.js';
16
+
17
+ export interface VolumesApi {
18
+ volumes: {
19
+ /**
20
+ * Managed volumes on the panel host or (0.16 T8) with `serverId` on a
21
+ * node, through its agent; node entries carry `serverId`. An older agent
22
+ * answers 422 `node_agent_outdated`.
23
+ */
24
+ list: (options?: { serverId?: number }) => Promise<VolumeListEntry[]>;
25
+ /** Delete a retained volume; with `serverId` (r466) on that node. */
26
+ remove: (name: string, options?: { serverId?: number }) => Promise<void>;
27
+ /**
28
+ * 0.16 T8: create a managed volume (`nd-svc-*` / `nd-db-*`) on the panel
29
+ * host or, with `serverId`, on a node. An existing volume answers 409
30
+ * `node_volume_exists`.
31
+ */
32
+ create: (input: VolumeCreateInput) => Promise<VolumeCreateResult>;
33
+ prune: () => Promise<{ ok: boolean; deleted: number; freedBytes: number }>;
34
+ /** File manager: list a directory inside the volume. */
35
+ listFiles: (name: string, path?: string) => Promise<{ path: string; entries: VolumeFileEntry[] }>;
36
+ /** File manager: read a file (base64 content). */
37
+ readFile: (name: string, path: string) => Promise<{ content: string; encoding: 'utf8' | 'base64' }>;
38
+ /** File manager: write/overwrite a file (base64 content). */
39
+ writeFile: (name: string, input: VolumeFileWriteInput) => Promise<{ ok: boolean }>;
40
+ /** File manager: create a directory. */
41
+ mkdir: (name: string, input: VolumePathCreateInput) => Promise<{ ok: boolean }>;
42
+ /** File manager: delete a file or directory (recursive). */
43
+ deleteFile: (name: string, path: string) => Promise<void>;
44
+ };
45
+ containers: {
46
+ inspect: (container: string) => Promise<ContainerInspectData>;
47
+ compose: (container: string) => Promise<ContainerComposeData>;
48
+ /** File manager: list a directory inside the container. */
49
+ listFiles: (container: string, path?: string) => Promise<{ path: string; entries: VolumeFileEntry[] }>;
50
+ /** File manager: read a file (base64 content). */
51
+ readFile: (container: string, path: string) => Promise<{ content: string; encoding: 'utf8' | 'base64' }>;
52
+ /** File manager: write/overwrite a file (base64 content). */
53
+ writeFile: (container: string, input: VolumeFileWriteInput) => Promise<{ ok: boolean }>;
54
+ /** File manager: create a directory. */
55
+ mkdir: (container: string, input: VolumePathCreateInput) => Promise<{ ok: boolean }>;
56
+ makeDir: (container: string, input: VolumePathCreateInput) => Promise<{ ok: boolean }>;
57
+ /** File manager: delete a file or directory (recursive). */
58
+ deleteFile: (container: string, path: string) => Promise<void>;
59
+ deletePath: (container: string, path: string) => Promise<void>;
60
+ };
61
+ /** Per-service additional volume attachments. Distinct from the instance-wide
62
+ * `volumes` namespace (which lists every Docker volume on the host). The
63
+ * list endpoint returns runtime-computed metadata (size, in-use, sharing);
64
+ * the create/update endpoints return the database row only. */
65
+ serviceVolumes: {
66
+ list: (serviceId: number) => Promise<Array<ServiceVolumeAttachment & { sizeBytes: number; inUse: boolean; sharedWith: number }>>;
67
+ /** Attach an existing managed volume or create a new one and attach it. */
68
+ create: (serviceId: number, input: CreateServiceVolumeAttachmentInput) => Promise<{ attachment: ServiceVolumeAttachment; deploymentId: number }>;
69
+ update: (serviceId: number, attachmentId: number, input: UpdateServiceVolumeAttachmentInput) => Promise<{ attachment: ServiceVolumeAttachment; deploymentId: number }>;
70
+ /** Detach. The underlying Docker volume is NOT deleted (data persists). */
71
+ remove: (serviceId: number, attachmentId: number) => Promise<void>;
72
+ /** Delete a first-boot-baked config file from the volume and queue a
73
+ * redeploy so the app regenerates it from the current environment
74
+ * (WordPress wp-config.php et al.). Exactly one of `attachmentId` /
75
+ * `volumeName` must be set; `filePath` is a single path segment. */
76
+ repairConfig: (
77
+ serviceId: number,
78
+ input: { filePath: string; attachmentId?: number; volumeName?: string },
79
+ ) => Promise<{ ok: boolean; deploymentId: number }>;
80
+ };
81
+ }
82
+
83
+ export function volumesApi(t: Transport): VolumesApi {
84
+ const { get, send, request } = t;
85
+ return {
86
+ volumes: {
87
+ list: (options) => get<VolumeListEntry[]>(apiGetUrl('/v1/volumes', { serverId: options?.serverId })),
88
+ create: (input) => send<VolumeCreateResult>('POST', '/v1/volumes', input),
89
+ listFiles: (name, path = '') =>
90
+ get<{ path: string; entries: VolumeFileEntry[] }>(`/v1/volumes/${encodeURIComponent(name)}/files?path=${encodeURIComponent(path)}`),
91
+ readFile: (name, path) =>
92
+ get<{ content: string; encoding: 'utf8' | 'base64' }>(`/v1/volumes/${encodeURIComponent(name)}/files/content?path=${encodeURIComponent(path)}`),
93
+ writeFile: (name, input) => send<{ ok: boolean }>('PUT', `/v1/volumes/${encodeURIComponent(name)}/files`, input),
94
+ mkdir: (name, input) => send<{ ok: boolean }>('POST', `/v1/volumes/${encodeURIComponent(name)}/files/dir`, input),
95
+ deleteFile: async (name, path) => {
96
+ await request(`/v1/volumes/${encodeURIComponent(name)}/files?path=${encodeURIComponent(path)}`, { method: 'DELETE' });
97
+ },
98
+ remove: async (name, options) => {
99
+ const node = options?.serverId === undefined ? '' : `?serverId=${options.serverId}`;
100
+ await request(`/v1/volumes/${encodeURIComponent(name)}${node}`, { method: 'DELETE' });
101
+ },
102
+ prune: () => send<{ ok: boolean; deleted: number; freedBytes: number }>('POST', '/v1/volumes/prune'),
103
+ },
104
+ containers: {
105
+ inspect: (container) => get<ContainerInspectData>(`/v1/containers/${encodeURIComponent(container)}/inspect`),
106
+ compose: (container) => get<ContainerComposeData>(`/v1/containers/${encodeURIComponent(container)}/compose`),
107
+ listFiles: (container, path = '/') =>
108
+ get<{ path: string; entries: VolumeFileEntry[] }>(`/v1/containers/${encodeURIComponent(container)}/files?path=${encodeURIComponent(path)}`),
109
+ readFile: (container, path) =>
110
+ get<{ content: string; encoding: 'utf8' | 'base64' }>(`/v1/containers/${encodeURIComponent(container)}/files/content?path=${encodeURIComponent(path)}`),
111
+ writeFile: (container, input) => send<{ ok: boolean }>('PUT', `/v1/containers/${encodeURIComponent(container)}/files`, input),
112
+ mkdir: (container, input) => send<{ ok: boolean }>('POST', `/v1/containers/${encodeURIComponent(container)}/files/dir`, input),
113
+ makeDir: (container, input) => send<{ ok: boolean }>('POST', `/v1/containers/${encodeURIComponent(container)}/files/dir`, input),
114
+ deleteFile: async (container, path) => {
115
+ await request(`/v1/containers/${encodeURIComponent(container)}/files?path=${encodeURIComponent(path)}`, { method: 'DELETE' });
116
+ },
117
+ deletePath: async (container, path) => {
118
+ await request(`/v1/containers/${encodeURIComponent(container)}/files?path=${encodeURIComponent(path)}`, { method: 'DELETE' });
119
+ },
120
+ },
121
+ serviceVolumes: {
122
+ list: (serviceId) => get<Array<ServiceVolumeAttachment & { sizeBytes: number; inUse: boolean; sharedWith: number }>>(`/v1/services/${serviceId}/volumes`),
123
+ create: (serviceId, input) =>
124
+ send<{ attachment: ServiceVolumeAttachment; deploymentId: number }>('POST', `/v1/services/${serviceId}/volumes`, input),
125
+ update: (serviceId, attachmentId, input) =>
126
+ send<{ attachment: ServiceVolumeAttachment; deploymentId: number }>(
127
+ 'PATCH',
128
+ `/v1/services/${serviceId}/volumes/${attachmentId}`,
129
+ input,
130
+ ),
131
+ remove: async (serviceId, attachmentId) => {
132
+ await request(`/v1/services/${serviceId}/volumes/${attachmentId}`, { method: 'DELETE' });
133
+ },
134
+ repairConfig: (serviceId, input) =>
135
+ send<{ ok: boolean; deploymentId: number }>('POST', `/v1/services/${serviceId}/volumes/config-repair`, input),
136
+ },
137
+ };
138
+ }
@@ -0,0 +1,254 @@
1
+ // Terminal sessions over WebSocket (0.15 T6).
2
+ // Split out of the former single-file SDK entry (src/index.ts); the public
3
+ // surface is re-exported unchanged from there.
4
+ import type {
5
+ TerminalSessionCreated,
6
+ TerminalTargetKind,
7
+ } from '@ninedeploy/schemas';
8
+ import { NineDeployError } from './errors.js';
9
+
10
+ /** Attach subprotocol (protocol v1) and the ticket's subprotocol prefix; mirror `@ninedeploy/schemas`. */
11
+ export const TERMINAL_SUBPROTOCOL = 'ninedeploy.terminal.v1';
12
+
13
+ export const TERMINAL_TICKET_PREFIX = 'ninedeploy.ticket.';
14
+
15
+ /** The server closes a socket whose frame exceeds 64 KiB (1009); input is sent in smaller pieces. */
16
+ export const TERMINAL_INPUT_CHUNK_BYTES = 32 * 1024;
17
+
18
+ /** Input typed before the shell is `ready` is held, up to this many bytes. */
19
+ export const TERMINAL_PENDING_INPUT_MAX = 64 * 1024;
20
+
21
+ /** What `attachInfo` needs from `terminals.create`'s answer. */
22
+ export type TerminalAttachSource = Pick<TerminalSessionCreated, 'ticket' | 'attachPath'>;
23
+
24
+ /** A page location, for a client whose `baseUrl` is relative (the dashboard). */
25
+ export interface WebSocketLocation {
26
+ protocol: string;
27
+ host: string;
28
+ }
29
+
30
+ export interface TerminalAttachInfo {
31
+ url: string;
32
+ /** `['ninedeploy.terminal.v1', 'ninedeploy.ticket.<ticket>']`: the server selects the first, so the ticket is never echoed. */
33
+ protocols: string[];
34
+ }
35
+
36
+ /**
37
+ * The attach WebSocket URL for `baseUrl` (absolute, or relative to
38
+ * `location` / `globalThis.location`) and the v1 subprotocols.
39
+ */
40
+ export function terminalAttachInfo(baseUrl: string, created: TerminalAttachSource, location?: WebSocketLocation): TerminalAttachInfo {
41
+ if (!/^\/v1\/terminals\/\d+\/attach$/.test(created.attachPath)) {
42
+ throw new NineDeployError(0, 'invalid_path', `Not a terminal attach path: ${created.attachPath}`);
43
+ }
44
+ if (!/^[A-Za-z0-9_-]{16,128}$/.test(created.ticket)) {
45
+ throw new NineDeployError(0, 'invalid_ticket', 'The terminal ticket is malformed');
46
+ }
47
+ const base = baseUrl.replace(/\/+$/, '');
48
+ let url: string;
49
+ if (/^https?:\/\//i.test(base)) {
50
+ url = `${base.replace(/^http/i, 'ws')}${created.attachPath}`;
51
+ } else {
52
+ const loc = location ?? (globalThis as { location?: WebSocketLocation }).location;
53
+ if (!loc) throw new NineDeployError(0, 'no_base_url', 'A relative baseUrl needs a page location to build a WebSocket URL');
54
+ const proto = loc.protocol === 'https:' ? 'wss' : 'ws';
55
+ url = `${proto}://${loc.host}${base}${created.attachPath}`;
56
+ }
57
+ return { url, protocols: [TERMINAL_SUBPROTOCOL, `${TERMINAL_TICKET_PREFIX}${created.ticket}`] };
58
+ }
59
+
60
+ /** A human sentence for an attach socket's close code. */
61
+ export function terminalCloseMessage(code: number): string {
62
+ switch (code) {
63
+ case 1000:
64
+ return 'The shell exited.';
65
+ case 1009:
66
+ return 'A message was too large (over 64 KiB), so the session was closed.';
67
+ case 4401:
68
+ return 'The session ticket was invalid or expired. Open a new terminal.';
69
+ case 4403:
70
+ return 'Access was refused: the session was revoked, or this page is not a panel origin.';
71
+ case 4408:
72
+ return 'The session was closed after being idle too long.';
73
+ case 4409:
74
+ return 'The session reached its maximum length.';
75
+ case 4410:
76
+ return 'An operator terminated this session.';
77
+ case 4429:
78
+ return 'Too many terminals are open. Close one and try again.';
79
+ case 4502:
80
+ return 'The target could not be reached.';
81
+ default:
82
+ return `The connection was lost (code ${code}).`;
83
+ }
84
+ }
85
+
86
+ /** The structural WebSocket the connection drives (browser `WebSocket`, Node's global one, or `ws`). */
87
+ export interface TerminalSocketLike {
88
+ binaryType: string;
89
+ readyState: number;
90
+ send(data: string | Uint8Array): void;
91
+ close(code?: number, reason?: string): void;
92
+ onopen: ((event: unknown) => void) | null;
93
+ onmessage: ((event: { data: unknown }) => void) | null;
94
+ onclose: ((event: { code: number; reason: string }) => void) | null;
95
+ onerror: ((event: unknown) => void) | null;
96
+ }
97
+
98
+ export type TerminalSocketFactory = (url: string, protocols: string[]) => TerminalSocketLike;
99
+
100
+ export interface TerminalReady {
101
+ sessionId: number;
102
+ target: { kind: TerminalTargetKind; label: string; serverId: number | null };
103
+ }
104
+
105
+ export interface TerminalHandlers {
106
+ /** The shell runs; input typed before this was held and is flushed now. */
107
+ onReady?: (ready: TerminalReady) => void;
108
+ /** Shell output. */
109
+ onData?: (bytes: Uint8Array) => void;
110
+ /** A server notice (why a session failed, an ignored control message). */
111
+ onNotice?: (message: string) => void;
112
+ /** The server's `exit` message: the shell's exit code (null when unknown) and the end reason. */
113
+ onExit?: (exit: { code: number | null; reason: string }) => void;
114
+ /** The socket closed; `message` explains the close code. */
115
+ onClose?: (close: { code: number; reason: string; message: string }) => void;
116
+ }
117
+
118
+ export interface TerminalConnectOptions {
119
+ /** Defaults to `globalThis.WebSocket`. */
120
+ socketFactory?: TerminalSocketFactory;
121
+ /** For a relative `baseUrl` outside a browser page. */
122
+ location?: WebSocketLocation;
123
+ /** Keep-alive ping interval (ms); pings do not reset the idle timeout. 0 disables. Default 30s. */
124
+ pingIntervalMs?: number;
125
+ }
126
+
127
+ export interface TerminalConnection {
128
+ /** True once the server sent `ready`. */
129
+ readonly ready: boolean;
130
+ /** Send input (held until `ready`, then sent in frames below the server's cap). */
131
+ write: (data: string | Uint8Array) => void;
132
+ /** Resize the terminal (clamped to 10–500 × 5–200; the last size before `ready` is sent on `ready`). */
133
+ resize: (cols: number, rows: number) => void;
134
+ close: () => void;
135
+ }
136
+
137
+ const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, Math.round(n)));
138
+
139
+ /** Timers and TextEncoder exist wherever the SDK runs; typed structurally (no DOM or Node lib types). */
140
+ const runtime = globalThis as unknown as {
141
+ setInterval: (fn: () => void, ms: number) => unknown;
142
+ clearInterval: (handle: unknown) => void;
143
+ TextEncoder: new () => { encode: (text: string) => Uint8Array };
144
+ };
145
+
146
+ function defaultSocketFactory(): TerminalSocketFactory {
147
+ const Ctor = (globalThis as { WebSocket?: new (url: string, protocols: string[]) => TerminalSocketLike }).WebSocket;
148
+ if (!Ctor) throw new NineDeployError(0, 'no_websocket', 'No WebSocket implementation is available; pass socketFactory');
149
+ return (url, protocols) => new Ctor(url, protocols);
150
+ }
151
+
152
+ function toBytes(data: unknown): Uint8Array {
153
+ if (data instanceof Uint8Array) return data;
154
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
155
+ const view = data as ArrayBufferView;
156
+ return new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
157
+ }
158
+
159
+ /**
160
+ * Attach to a terminal session over protocol v1 (`ninedeploy.terminal.v1`).
161
+ *
162
+ * Binary frames carry input and output; text frames carry the JSON control
163
+ * messages. Input written before the server's `ready` is held (up to 64 KiB)
164
+ * and flushed on `ready`, because the server may drop input that arrives
165
+ * while it is still starting the shell.
166
+ */
167
+ export function connectTerminal(info: TerminalAttachInfo, handlers: TerminalHandlers = {}, options: TerminalConnectOptions = {}): TerminalConnection {
168
+ const factory = options.socketFactory ?? defaultSocketFactory();
169
+ const socket = factory(info.url, info.protocols);
170
+ socket.binaryType = 'arraybuffer';
171
+ const encoder = new runtime.TextEncoder();
172
+ let ready = false;
173
+ let closed = false;
174
+ let pending: Uint8Array[] = [];
175
+ let pendingBytes = 0;
176
+ let size: { cols: number; rows: number } | null = null;
177
+ let ping: unknown;
178
+
179
+ const sendInput = (bytes: Uint8Array) => {
180
+ for (let at = 0; at < bytes.length; at += TERMINAL_INPUT_CHUNK_BYTES) {
181
+ socket.send(bytes.subarray(at, at + TERMINAL_INPUT_CHUNK_BYTES));
182
+ }
183
+ };
184
+ const sendResize = (cols: number, rows: number) => socket.send(JSON.stringify({ t: 'resize', cols, rows }));
185
+
186
+ socket.onmessage = (event) => {
187
+ if (typeof event.data !== 'string') {
188
+ handlers.onData?.(toBytes(event.data));
189
+ return;
190
+ }
191
+ let msg: { t?: unknown; message?: unknown; code?: unknown; reason?: unknown; sessionId?: unknown; target?: unknown };
192
+ try {
193
+ msg = JSON.parse(event.data) as typeof msg;
194
+ } catch {
195
+ return;
196
+ }
197
+ if (msg.t === 'ready') {
198
+ ready = true;
199
+ if (size) sendResize(size.cols, size.rows);
200
+ const held = pending;
201
+ pending = [];
202
+ pendingBytes = 0;
203
+ for (const bytes of held) sendInput(bytes);
204
+ handlers.onReady?.({ sessionId: msg.sessionId as number, target: msg.target as TerminalReady['target'] });
205
+ const every = options.pingIntervalMs ?? 30_000;
206
+ if (every > 0) {
207
+ ping = runtime.setInterval(() => {
208
+ if (!closed) socket.send(JSON.stringify({ t: 'ping' }));
209
+ }, every);
210
+ }
211
+ } else if (msg.t === 'notice') {
212
+ handlers.onNotice?.(String(msg.message));
213
+ } else if (msg.t === 'exit') {
214
+ handlers.onExit?.({ code: typeof msg.code === 'number' ? msg.code : null, reason: String(msg.reason) });
215
+ }
216
+ };
217
+ socket.onclose = (event) => {
218
+ closed = true;
219
+ ready = false;
220
+ runtime.clearInterval(ping);
221
+ handlers.onClose?.({ code: event.code, reason: event.reason, message: terminalCloseMessage(event.code) });
222
+ };
223
+ socket.onerror = () => {
224
+ /* the close event follows and carries the code */
225
+ };
226
+
227
+ return {
228
+ get ready() {
229
+ return ready;
230
+ },
231
+ write: (data) => {
232
+ if (closed) return;
233
+ const bytes = typeof data === 'string' ? encoder.encode(data) : data;
234
+ if (bytes.length === 0) return;
235
+ if (ready) {
236
+ sendInput(bytes);
237
+ return;
238
+ }
239
+ if (pendingBytes + bytes.length > TERMINAL_PENDING_INPUT_MAX) return;
240
+ pending.push(bytes);
241
+ pendingBytes += bytes.length;
242
+ },
243
+ resize: (cols, rows) => {
244
+ size = { cols: clamp(cols, 10, 500), rows: clamp(rows, 5, 200) };
245
+ if (ready && !closed) sendResize(size.cols, size.rows);
246
+ },
247
+ close: () => {
248
+ if (closed) return;
249
+ closed = true;
250
+ runtime.clearInterval(ping);
251
+ socket.close(1000, 'client closed');
252
+ },
253
+ };
254
+ }
@@ -0,0 +1,154 @@
1
+ // HTTP request core shared by every resource module.
2
+ // Split out of the former single-file SDK entry (src/index.ts); the public
3
+ // surface is re-exported unchanged from there.
4
+ import { NineDeployError } from './errors.js';
5
+
6
+ /**
7
+ * Minimal structural fetch type so the SDK is isomorphic (browser + Node)
8
+ * without pulling in DOM lib types. Both `globalThis.fetch` implementations
9
+ * satisfy this shape.
10
+ */
11
+ export type FetchLike = (input: string, init: {
12
+ method?: string;
13
+ headers?: Record<string, string>;
14
+ body?: string;
15
+ credentials?: 'omit' | 'same-origin' | 'include';
16
+ }) => Promise<{
17
+ ok: boolean;
18
+ status: number;
19
+ text(): Promise<string>;
20
+ /** Optional so minimal fetch stand-ins stay assignable; a real fetch has it (F1010). */
21
+ headers?: { get(name: string): string | null };
22
+ }>
23
+
24
+ export interface NineDeployClientOptions {
25
+ /** Base URL of the NineDeploy API, e.g. http://localhost:3000. */
26
+ baseUrl: string;
27
+ /** Returns the current access token (or bearer credential). */
28
+ getToken?: () => string | undefined | null;
29
+ /**
30
+ * Per-request timeout in milliseconds. A stalled backend must not hang
31
+ * callers forever (worst in CI, where nothing is interactive to Ctrl-C).
32
+ * Defaults to 30_000; pass `0` to disable entirely.
33
+ */
34
+ timeoutMs?: number;
35
+ /** Custom fetch implementation (defaults to globalThis.fetch). */
36
+ fetch?: FetchLike;
37
+ }
38
+
39
+ export interface RequestInit {
40
+ method?: string;
41
+ credentials?: 'omit' | 'same-origin' | 'include';
42
+ headers?: Record<string, string>;
43
+ body?: unknown;
44
+ /**
45
+ * Propagated to fetch as-is. Typed structurally: the SDK compiles without
46
+ * DOM lib types, and only `AbortSignal.timeout()` ever produces one here.
47
+ */
48
+ signal?: unknown;
49
+ /** 0.14: a binary body, sent as-is (`application/octet-stream`). */
50
+ raw?: Uint8Array;
51
+ /** 0.14: keep a refusal's top-level `errors` / `warnings` as the error's `details`. */
52
+ findings?: boolean;
53
+ }
54
+
55
+ const NO_FETCH = (): Promise<never> =>
56
+ Promise.reject(new NineDeployError(0, 'no_fetch', 'No fetch implementation is available'));
57
+
58
+ /** The request core every resource module calls through. */
59
+ export interface Transport {
60
+ baseUrl: string;
61
+ requestWithHeaders<T>(path: string, init?: RequestInit, minTimeoutMs?: number): Promise<{ data: T; header: (name: string) => string | null }>;
62
+ request<T>(path: string, init?: RequestInit, minTimeoutMs?: number): Promise<T>;
63
+ get<T>(path: string): Promise<T>;
64
+ send<T>(method: string, path: string, body?: unknown): Promise<T>;
65
+ }
66
+
67
+ export function createTransport(opts: NineDeployClientOptions): Transport {
68
+ const baseUrl = opts.baseUrl.replace(/\/$/, '');
69
+ const fetchImpl: FetchLike = opts.fetch ?? ((globalThis as { fetch?: FetchLike }).fetch ?? NO_FETCH);
70
+ /** Default per-request budget when the caller does not configure one. */
71
+ const DEFAULT_TIMEOUT_MS = 30_000;
72
+
73
+ /** A call's parsed body plus read access to its response headers (F1010). */
74
+ async function requestWithHeaders<T>(
75
+ path: string,
76
+ init: RequestInit = {},
77
+ minTimeoutMs?: number,
78
+ ): Promise<{ data: T; header: (name: string) => string | null }> {
79
+ const token = opts.getToken?.();
80
+ const headers: Record<string, string> = { ...(init.headers ?? {}) };
81
+ if (token) headers['Authorization'] = `Bearer ${token}`;
82
+ if (init.body !== undefined && headers['Content-Type'] === undefined) {
83
+ headers['Content-Type'] = 'application/json';
84
+ }
85
+ const body = init.body === undefined ? undefined : JSON.stringify(init.body);
86
+ const configured = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
87
+ // A long-running call (image pull) may widen the budget, never narrow it;
88
+ // `0` (timeouts disabled) stays disabled.
89
+ const timeoutMs = configured > 0 && minTimeoutMs !== undefined ? Math.max(configured, minTimeoutMs) : configured;
90
+ // AbortSignal.timeout exists everywhere the SDK runs (Node 17.3+, all
91
+ // modern browsers); it is resolved off globalThis and attached opaquely —
92
+ // FetchLike's shape stays untouched (widening it with `signal: unknown`
93
+ // breaks assigning a real `typeof fetch` at the call sites).
94
+ const out: { method?: string; headers?: Record<string, string>; body?: string; credentials?: 'omit' | 'same-origin' | 'include' } = {
95
+ method: init.method ?? 'GET',
96
+ headers,
97
+ body,
98
+ };
99
+ if (init.credentials !== undefined) out.credentials = init.credentials;
100
+ if (init.raw !== undefined) {
101
+ // Attached opaquely, like the signal: FetchLike's `body` stays `string`.
102
+ headers['Content-Type'] = 'application/octet-stream';
103
+ (out as { body?: unknown }).body = init.raw;
104
+ }
105
+ if (timeoutMs > 0) {
106
+ const abort = (globalThis as { AbortSignal?: { timeout: (ms: number) => unknown } }).AbortSignal;
107
+ if (abort) (out as { signal?: unknown }).signal = abort.timeout(timeoutMs);
108
+ }
109
+
110
+ const res = await fetchImpl(`${baseUrl}${path}`, out);
111
+ const header = (name: string): string | null => (typeof res.headers?.get === 'function' ? res.headers.get(name) : null);
112
+ const text = await res.text();
113
+ // Guard the parse: proxies (HTML 502 pages, empty bodies) must surface as a
114
+ // typed error, not an opaque SyntaxError.
115
+ let parsed: unknown;
116
+ try {
117
+ parsed = text ? (JSON.parse(text) as unknown) : undefined;
118
+ } catch {
119
+ parsed = undefined;
120
+ }
121
+
122
+ if (!res.ok) {
123
+ if (parsed === undefined) throw new NineDeployError(res.status, 'http_error', `Request failed (${res.status})`);
124
+ const failure = NineDeployError.fromBody(res.status, parsed);
125
+ const findings = parsed as { errors?: unknown; warnings?: unknown };
126
+ if (init.findings && Array.isArray(findings.errors)) {
127
+ throw new NineDeployError(failure.status, failure.code, failure.message, {
128
+ errors: findings.errors,
129
+ warnings: Array.isArray(findings.warnings) ? findings.warnings : [],
130
+ });
131
+ }
132
+ throw failure;
133
+ }
134
+ // An ok response with an empty/unparseable body (204s, HTML pages from a
135
+ // misconfigured proxy) must never resolve to `undefined` — query functions
136
+ // built on this client would crash TanStack Query ("Query data cannot be
137
+ // undefined"). Empty 2xx bodies resolve to {}; non-JSON bodies surface as
138
+ // a typed error so callers see the real problem instead of a crash.
139
+ if (parsed === undefined) {
140
+ if (text === '') return { data: {} as T, header };
141
+ throw new NineDeployError(res.status, 'invalid_response', `Expected JSON but received a non-JSON body (status ${res.status})`);
142
+ }
143
+ return { data: parsed as T, header };
144
+ }
145
+
146
+ async function request<T>(path: string, init: RequestInit = {}, minTimeoutMs?: number): Promise<T> {
147
+ return (await requestWithHeaders<T>(path, init, minTimeoutMs)).data;
148
+ }
149
+
150
+ const get = <T>(path: string) => request<T>(path);
151
+ const send = <T>(method: string, path: string, body?: unknown) => request<T>(path, { method, body });
152
+
153
+ return { baseUrl, requestWithHeaders, request, get, send };
154
+ }