@shipstatic/drop 1.0.3 → 2.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -124
- package/dist/index.cjs +534 -9828
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -66
- package/dist/index.d.ts +27 -66
- package/dist/index.js +534 -9817
- package/dist/index.js.map +1 -1
- package/dist/testing.cjs +65 -96
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +47 -67
- package/dist/testing.d.ts +47 -67
- package/dist/testing.js +65 -92
- package/dist/testing.js.map +1 -1
- package/dist/useDrop-Bmjfjp_o.d.cts +140 -0
- package/dist/useDrop-Bmjfjp_o.d.ts +140 -0
- package/package.json +45 -26
- package/dist/useDrop-BAYclQ_x.d.cts +0 -147
- package/dist/useDrop-BAYclQ_x.d.ts +0 -147
package/dist/testing.js
CHANGED
|
@@ -1,37 +1,62 @@
|
|
|
1
|
-
|
|
1
|
+
// node_modules/.pnpm/@shipstatic+types@2.2.1-beta.0/node_modules/@shipstatic/types/dist/index.js
|
|
2
|
+
var ErrorType = {
|
|
3
|
+
/** Validation failed (400). Input shape is wrong. */
|
|
4
|
+
Validation: "validation_failed",
|
|
5
|
+
/** Resource not found (404). */
|
|
6
|
+
NotFound: "not_found",
|
|
7
|
+
/** Authenticated but not allowed (403). User lacks permission for this action. */
|
|
8
|
+
Forbidden: "forbidden",
|
|
9
|
+
/** Rate limit exceeded (429). */
|
|
10
|
+
RateLimit: "rate_limit_exceeded",
|
|
11
|
+
/** Authentication required or failed (401). Missing/invalid credentials. */
|
|
12
|
+
Authentication: "authentication_failed",
|
|
13
|
+
/** Business rule violation. Catch-all for 4xx state-rule errors that aren't more specific. */
|
|
14
|
+
Business: "business_logic_error",
|
|
15
|
+
/** API server error (500). Generic server-side fault. */
|
|
16
|
+
Api: "internal_server_error",
|
|
17
|
+
/** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */
|
|
18
|
+
Network: "network_error",
|
|
19
|
+
/** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */
|
|
20
|
+
Cancelled: "operation_cancelled",
|
|
21
|
+
/** File operation error. Client-side only — set by SDK during local file processing; never produced server-side. */
|
|
22
|
+
File: "file_error",
|
|
23
|
+
/** Configuration error. Client-side only — set by SDK during config parsing/validation; never produced server-side. */
|
|
24
|
+
Config: "config_error"
|
|
25
|
+
};
|
|
26
|
+
var CLIENT_ONLY_ERROR_TYPES = /* @__PURE__ */ new Set([
|
|
27
|
+
ErrorType.Network,
|
|
28
|
+
ErrorType.Cancelled,
|
|
29
|
+
ErrorType.File,
|
|
30
|
+
ErrorType.Config
|
|
31
|
+
]);
|
|
32
|
+
new Set(Object.values(ErrorType).filter((t) => !CLIENT_ONLY_ERROR_TYPES.has(t)));
|
|
33
|
+
var FileValidationStatus = {
|
|
34
|
+
/** File passed validation and is ready for deployment */
|
|
35
|
+
READY: "ready"
|
|
36
|
+
};
|
|
2
37
|
|
|
3
38
|
// src/testing.ts
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
needsBuild = false
|
|
11
|
-
} = options;
|
|
12
|
-
const validFiles = files.filter((f) => f.status === "ready");
|
|
39
|
+
var noop = () => {
|
|
40
|
+
};
|
|
41
|
+
function createMockDrop(overrides = {}) {
|
|
42
|
+
const phase = overrides.phase ?? "idle";
|
|
43
|
+
const files = overrides.files ?? [];
|
|
44
|
+
const validFiles = overrides.validFiles ?? files.filter((f) => f.status === FileValidationStatus.READY);
|
|
13
45
|
return {
|
|
14
|
-
// State
|
|
15
46
|
phase,
|
|
16
47
|
isProcessing: phase === "processing",
|
|
17
|
-
isDragging:
|
|
18
|
-
isInteractive: phase === "idle" || phase === "
|
|
48
|
+
isDragging: false,
|
|
49
|
+
isInteractive: phase === "idle" || phase === "ready",
|
|
19
50
|
hasError: phase === "error",
|
|
20
51
|
files,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
onDragLeave: () => {
|
|
30
|
-
},
|
|
31
|
-
onDrop: () => {
|
|
32
|
-
},
|
|
33
|
-
...opts?.clickable !== false && { onClick: () => {
|
|
34
|
-
} }
|
|
52
|
+
sourceName: "",
|
|
53
|
+
status: null,
|
|
54
|
+
needsBuild: false,
|
|
55
|
+
getDropzoneProps: (options) => ({
|
|
56
|
+
onDragOver: noop,
|
|
57
|
+
onDragLeave: noop,
|
|
58
|
+
onDrop: noop,
|
|
59
|
+
...options?.clickable !== false && { onClick: noop }
|
|
35
60
|
}),
|
|
36
61
|
getInputProps: () => ({
|
|
37
62
|
ref: { current: null },
|
|
@@ -39,57 +64,20 @@ function createMockDrop(options = {}) {
|
|
|
39
64
|
style: { display: "none" },
|
|
40
65
|
multiple: true,
|
|
41
66
|
webkitdirectory: "",
|
|
42
|
-
onChange:
|
|
43
|
-
}
|
|
67
|
+
onChange: noop
|
|
44
68
|
}),
|
|
45
|
-
|
|
46
|
-
open: () => {
|
|
47
|
-
},
|
|
69
|
+
open: noop,
|
|
48
70
|
processFiles: async () => {
|
|
49
71
|
},
|
|
50
|
-
reset:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
72
|
+
reset: noop,
|
|
73
|
+
validFiles,
|
|
74
|
+
getFilesForUpload: () => validFiles.map((f) => f.file),
|
|
75
|
+
// Explicit values win over every derivation above.
|
|
76
|
+
...overrides
|
|
54
77
|
};
|
|
55
78
|
}
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
let openCalls = 0;
|
|
59
|
-
let processFilesCalls = [];
|
|
60
|
-
let resetCalls = 0;
|
|
61
|
-
const trackedSpies = {
|
|
62
|
-
open: Object.assign(() => {
|
|
63
|
-
openCalls++;
|
|
64
|
-
}, {
|
|
65
|
-
calls: () => openCalls,
|
|
66
|
-
toHaveBeenCalled: () => openCalls > 0
|
|
67
|
-
}),
|
|
68
|
-
processFiles: Object.assign(async (files) => {
|
|
69
|
-
processFilesCalls.push(files);
|
|
70
|
-
}, {
|
|
71
|
-
calls: () => processFilesCalls,
|
|
72
|
-
toHaveBeenCalled: () => processFilesCalls.length > 0,
|
|
73
|
-
toHaveBeenCalledWith: (files) => processFilesCalls.some((c) => c === files)
|
|
74
|
-
}),
|
|
75
|
-
reset: Object.assign(() => {
|
|
76
|
-
resetCalls++;
|
|
77
|
-
}, {
|
|
78
|
-
calls: () => resetCalls,
|
|
79
|
-
toHaveBeenCalled: () => resetCalls > 0
|
|
80
|
-
}),
|
|
81
|
-
getFilesForUpload: baseDrop.getFilesForUpload
|
|
82
|
-
};
|
|
83
|
-
return {
|
|
84
|
-
drop: {
|
|
85
|
-
...baseDrop,
|
|
86
|
-
open: trackedSpies.open,
|
|
87
|
-
processFiles: trackedSpies.processFiles,
|
|
88
|
-
reset: trackedSpies.reset,
|
|
89
|
-
getFilesForUpload: trackedSpies.getFilesForUpload
|
|
90
|
-
},
|
|
91
|
-
spies: trackedSpies
|
|
92
|
-
};
|
|
79
|
+
function mockUseDrop(overrides = {}) {
|
|
80
|
+
return () => createMockDrop(overrides);
|
|
93
81
|
}
|
|
94
82
|
var mockFileIdCounter = 0;
|
|
95
83
|
function createMockProcessedFile(name, options = {}) {
|
|
@@ -97,7 +85,7 @@ function createMockProcessedFile(name, options = {}) {
|
|
|
97
85
|
path = name,
|
|
98
86
|
content = "test content",
|
|
99
87
|
type = "text/plain",
|
|
100
|
-
status =
|
|
88
|
+
status = FileValidationStatus.READY,
|
|
101
89
|
statusMessage
|
|
102
90
|
} = options;
|
|
103
91
|
const file = new File([content], name, { type });
|
|
@@ -108,16 +96,13 @@ function createMockProcessedFile(name, options = {}) {
|
|
|
108
96
|
name,
|
|
109
97
|
size: file.size,
|
|
110
98
|
type,
|
|
111
|
-
lastModified:
|
|
99
|
+
lastModified: file.lastModified,
|
|
112
100
|
status,
|
|
113
101
|
statusMessage
|
|
114
102
|
};
|
|
115
103
|
}
|
|
116
|
-
function createMockFile(name, content = "test content", type = "text/plain") {
|
|
117
|
-
return new File([content], name, { type, lastModified: Date.now() });
|
|
118
|
-
}
|
|
119
104
|
function createMockFileWithPath(name, webkitRelativePath, content = "test content", type = "text/plain") {
|
|
120
|
-
const file =
|
|
105
|
+
const file = new File([content], name, { type });
|
|
121
106
|
Object.defineProperty(file, "webkitRelativePath", {
|
|
122
107
|
value: webkitRelativePath,
|
|
123
108
|
writable: false,
|
|
@@ -126,19 +111,7 @@ function createMockFileWithPath(name, webkitRelativePath, content = "test conten
|
|
|
126
111
|
});
|
|
127
112
|
return file;
|
|
128
113
|
}
|
|
129
|
-
function createMockErrorStatus(title = "Validation Failed", details = "One or more files failed validation", errors = []) {
|
|
130
|
-
return { title, details, errors };
|
|
131
|
-
}
|
|
132
|
-
function createMockProcessingStatus(title = "Processing...", details = "Validating and preparing files.") {
|
|
133
|
-
return { title, details };
|
|
134
|
-
}
|
|
135
|
-
function createMockReadyStatus(fileCount) {
|
|
136
|
-
return {
|
|
137
|
-
title: "Ready",
|
|
138
|
-
details: `${pluralize(fileCount, "file", "files", true)} ready.`
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
114
|
|
|
142
|
-
export { createMockDrop,
|
|
115
|
+
export { createMockDrop, createMockFileWithPath, createMockProcessedFile, mockUseDrop };
|
|
143
116
|
//# sourceMappingURL=testing.js.map
|
|
144
117
|
//# sourceMappingURL=testing.js.map
|
package/dist/testing.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/testing.ts"],"names":[],"mappings":"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","file":"testing.js","sourcesContent":["/**\n * Test utilities for @shipstatic/drop\n *\n * Import from '@shipstatic/drop/testing' in your test files:\n *\n * ```typescript\n * import { createMockDrop, createMockFile } from '@shipstatic/drop/testing';\n * ```\n */\n\nimport { pluralize } from '@shipstatic/ship';\nimport type { ProcessedFile, DropStatus, DropStateValue } from './types';\nimport type { DropReturn, DropzonePropsOptions } from './hooks/useDrop';\n\n// ============================================================================\n// Mock Drop Hook Return\n// ============================================================================\n\n/**\n * Options for creating a mock drop return value\n */\nexport interface MockDropOptions {\n phase?: DropStateValue;\n files?: ProcessedFile[];\n sourceName?: string;\n status?: DropStatus | null;\n needsBuild?: boolean;\n}\n\n/**\n * Creates a mock DropReturn for testing components that receive drop as a prop\n *\n * @example\n * ```tsx\n * import { createMockDrop, createMockProcessedFile } from '@shipstatic/drop/testing';\n *\n * it('renders file count when files are ready', () => {\n * const drop = createMockDrop({\n * phase: 'ready',\n * files: [createMockProcessedFile('index.html')],\n * });\n *\n * render(<DeployDropArea drop={drop} />);\n * expect(screen.getByText('1 file ready')).toBeInTheDocument();\n * });\n * ```\n */\nexport function createMockDrop(options: MockDropOptions = {}): DropReturn {\n const {\n phase = 'idle',\n files = [],\n sourceName = '',\n status = null,\n needsBuild = false,\n } = options;\n\n const validFiles = files.filter(f => f.status === 'ready');\n\n return {\n // State\n phase,\n isProcessing: phase === 'processing',\n isDragging: phase === 'dragging',\n isInteractive: phase === 'idle' || phase === 'dragging' || phase === 'ready',\n hasError: phase === 'error',\n files,\n validFiles,\n sourceName,\n status,\n needsBuild,\n\n // Prop getters - return minimal objects for spreading\n getDropzoneProps: (opts?: DropzonePropsOptions) => ({\n onDragOver: () => {},\n onDragLeave: () => {},\n onDrop: () => {},\n ...(opts?.clickable !== false && { onClick: () => {} }),\n }),\n getInputProps: () => ({\n ref: { current: null },\n type: 'file' as const,\n style: { display: 'none' },\n multiple: true,\n webkitdirectory: '',\n onChange: () => {},\n }),\n\n // Actions - no-op by default, can be spied on\n open: () => {},\n processFiles: async () => {},\n reset: () => {},\n\n // Helpers\n getFilesForUpload: () => validFiles.map(f => f.file),\n };\n}\n\n/**\n * Creates a mock drop with spy functions for testing interactions\n *\n * @example\n * ```tsx\n * import { createMockDropWithSpies } from '@shipstatic/drop/testing';\n *\n * it('calls reset when Clear button is clicked', async () => {\n * const { drop, spies } = createMockDropWithSpies({ phase: 'ready', files: [...] });\n *\n * render(<DeployDropArea drop={drop} />);\n * await userEvent.click(screen.getByText('Clear'));\n *\n * expect(spies.reset).toHaveBeenCalled();\n * });\n * ```\n */\nexport function createMockDropWithSpies(options: MockDropOptions = {}): {\n drop: DropReturn;\n spies: {\n open: () => void;\n processFiles: (files: File[]) => Promise<void>;\n reset: () => void;\n getFilesForUpload: () => File[];\n };\n} {\n const baseDrop = createMockDrop(options);\n\n const spies = {\n open: createNoopSpy(),\n processFiles: createAsyncNoopSpy(),\n reset: createNoopSpy(),\n getFilesForUpload: (() => baseDrop.getFilesForUpload()) as () => File[],\n };\n\n // Track calls manually (works without vitest in runtime)\n let openCalls = 0;\n let processFilesCalls: File[][] = [];\n let resetCalls = 0;\n\n const trackedSpies = {\n open: Object.assign(() => { openCalls++; }, {\n calls: () => openCalls,\n toHaveBeenCalled: () => openCalls > 0,\n }),\n processFiles: Object.assign(async (files: File[]) => { processFilesCalls.push(files); }, {\n calls: () => processFilesCalls,\n toHaveBeenCalled: () => processFilesCalls.length > 0,\n toHaveBeenCalledWith: (files: File[]) => processFilesCalls.some(c => c === files),\n }),\n reset: Object.assign(() => { resetCalls++; }, {\n calls: () => resetCalls,\n toHaveBeenCalled: () => resetCalls > 0,\n }),\n getFilesForUpload: baseDrop.getFilesForUpload,\n };\n\n return {\n drop: {\n ...baseDrop,\n open: trackedSpies.open,\n processFiles: trackedSpies.processFiles,\n reset: trackedSpies.reset,\n getFilesForUpload: trackedSpies.getFilesForUpload,\n },\n spies: trackedSpies,\n };\n}\n\n// ============================================================================\n// Mock File Utilities\n// ============================================================================\n\nlet mockFileIdCounter = 0;\n\n/**\n * Creates a mock ProcessedFile for testing\n */\nexport function createMockProcessedFile(\n name: string,\n options: {\n path?: string;\n content?: string;\n type?: string;\n status?: 'ready' | 'validation_failed' | 'processing_error' | 'excluded';\n statusMessage?: string;\n } = {}\n): ProcessedFile {\n const {\n path = name,\n content = 'test content',\n type = 'text/plain',\n status = 'ready',\n statusMessage,\n } = options;\n\n const file = new File([content], name, { type });\n\n return {\n id: `mock-file-${++mockFileIdCounter}`,\n file,\n path,\n name,\n size: file.size,\n type,\n lastModified: Date.now(),\n status,\n statusMessage,\n };\n}\n\n/**\n * Creates a mock File object\n */\nexport function createMockFile(\n name: string,\n content: string = 'test content',\n type: string = 'text/plain'\n): File {\n return new File([content], name, { type, lastModified: Date.now() });\n}\n\n/**\n * Creates a mock File object with webkitRelativePath set\n */\nexport function createMockFileWithPath(\n name: string,\n webkitRelativePath: string,\n content: string = 'test content',\n type: string = 'text/plain'\n): File {\n const file = createMockFile(name, content, type);\n Object.defineProperty(file, 'webkitRelativePath', {\n value: webkitRelativePath,\n writable: false,\n enumerable: true,\n configurable: true,\n });\n return file;\n}\n\n// ============================================================================\n// Mock Status Utilities\n// ============================================================================\n\n/**\n * Creates a mock error status\n */\nexport function createMockErrorStatus(\n title: string = 'Validation Failed',\n details: string = 'One or more files failed validation',\n errors: string[] = []\n): DropStatus {\n return { title, details, errors };\n}\n\n/**\n * Creates a mock processing status\n */\nexport function createMockProcessingStatus(\n title: string = 'Processing...',\n details: string = 'Validating and preparing files.'\n): DropStatus {\n return { title, details };\n}\n\n/**\n * Creates a mock ready status\n */\nexport function createMockReadyStatus(fileCount: number): DropStatus {\n return {\n title: 'Ready',\n details: `${pluralize(fileCount, 'file', 'files', true)} ready.`,\n };\n}\n\n// ============================================================================\n// Internal Helpers\n// ============================================================================\n\nfunction createNoopSpy(): () => void {\n return () => {};\n}\n\nfunction createAsyncNoopSpy(): () => Promise<void> {\n return async () => {};\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../node_modules/.pnpm/@shipstatic+types@2.2.1-beta.0/node_modules/@shipstatic/types/dist/index.js","../src/testing.ts"],"names":[],"mappings":"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","file":"testing.js","sourcesContent":["/**\n * @file Shared TypeScript types, constants, and utilities for the ShipStatic platform.\n * This package is the single source of truth for all shared data structures.\n */\n// =============================================================================\n// I. CORE ENTITIES\n// =============================================================================\n/**\n * Deployment status constants\n */\nexport const DeploymentStatus = {\n PENDING: 'pending',\n SUCCESS: 'success',\n FAILED: 'failed',\n DELETING: 'deleting',\n};\n// =============================================================================\n// DOMAIN TYPES\n// =============================================================================\n/**\n * Domain status constants\n *\n * - PENDING: DNS not configured\n * - PARTIAL: DNS partially configured\n * - SUCCESS: DNS fully verified\n * - PAUSED: Domain paused due to plan enforcement (billing)\n */\nexport const DomainStatus = {\n PENDING: 'pending',\n PARTIAL: 'partial',\n SUCCESS: 'success',\n PAUSED: 'paused',\n};\n// =============================================================================\n// ACCOUNT TYPES\n// =============================================================================\n/**\n * Account plan constants\n */\nexport const AccountPlan = {\n FREE: 'free',\n STANDARD: 'standard',\n SPONSORED: 'sponsored',\n ENTERPRISE: 'enterprise',\n SUSPENDED: 'suspended',\n TERMINATING: 'terminating',\n TERMINATED: 'terminated',\n};\n// =============================================================================\n// ERROR SYSTEM\n// =============================================================================\n/**\n * All possible error types in the ShipStatic platform.\n *\n * Developer-friendly key names map to stable wire-format string values.\n * Both the value and the type are exported under the same name so callers\n * can use `ErrorType.Validation` (value comparison) and `: ErrorType` (type\n * annotation) without ceremony — matching the pattern other status objects\n * (`DeploymentStatus`, `DomainStatus`, `AccountPlan`, `AuthMethod`) follow.\n */\nexport const ErrorType = {\n /** Validation failed (400). Input shape is wrong. */\n Validation: 'validation_failed',\n /** Resource not found (404). */\n NotFound: 'not_found',\n /** Authenticated but not allowed (403). User lacks permission for this action. */\n Forbidden: 'forbidden',\n /** Rate limit exceeded (429). */\n RateLimit: 'rate_limit_exceeded',\n /** Authentication required or failed (401). Missing/invalid credentials. */\n Authentication: 'authentication_failed',\n /** Business rule violation. Catch-all for 4xx state-rule errors that aren't more specific. */\n Business: 'business_logic_error',\n /** API server error (500). Generic server-side fault. */\n Api: 'internal_server_error',\n /** Network/connection error. Client-side only — set by HTTP clients on fetch failure; never produced server-side. */\n Network: 'network_error',\n /** Operation was cancelled. Client-side only — set on `AbortSignal` abort; never produced server-side. */\n Cancelled: 'operation_cancelled',\n /** File operation error. Client-side only — set by SDK during local file processing; never produced server-side. */\n File: 'file_error',\n /** Configuration error. Client-side only — set by SDK during config parsing/validation; never produced server-side. */\n Config: 'config_error',\n};\n/**\n * Error types that originate exclusively on the client (HTTP clients, SDK\n * file processing, local config parsing). These never appear on the wire\n * from the server, so `fromHttpResponse` will not trust them even if a\n * misbehaving server claims one in `body.error`.\n */\nconst CLIENT_ONLY_ERROR_TYPES = new Set([\n ErrorType.Network,\n ErrorType.Cancelled,\n ErrorType.File,\n ErrorType.Config,\n]);\n/**\n * Categorizes error types for the `isClientError` / `isNetworkError` /\n * `isAuthError` helpers. Each `Set` is typed against the wider `ErrorType`\n * union so `.has(error.type)` accepts any value from the union.\n */\nconst ERROR_CATEGORIES = {\n client: new Set([\n ErrorType.Business,\n ErrorType.Config,\n ErrorType.File,\n ErrorType.Forbidden,\n ErrorType.Validation,\n ]),\n network: new Set([ErrorType.Network]),\n auth: new Set([ErrorType.Authentication]),\n};\n/**\n * Error types the server can legitimately produce on the wire. Used by\n * `ShipError.fromHttpResponse` to validate the body's `error` field before\n * trusting it as `ShipError.type`. Derived by exclusion from\n * `CLIENT_ONLY_ERROR_TYPES` so adding a new server-producible type to\n * `ErrorType` is automatically picked up.\n */\nconst SERVER_PRODUCIBLE_ERROR_TYPES = new Set(Object.values(ErrorType).filter((t) => !CLIENT_ONLY_ERROR_TYPES.has(t)));\n/**\n * Simple unified error class for both API and SDK\n */\nexport class ShipError extends Error {\n type;\n status;\n details;\n constructor(type, message, status, details) {\n super(message);\n this.type = type;\n this.status = status;\n this.details = details;\n this.name = 'ShipError';\n }\n /** Convert to wire format */\n toResponse() {\n // Strip authentication details when they carry an `internal` telemetry\n // tag (see `ShipError.authentication` JSDoc) — these are server-side\n // diagnostics like 'session_invalid' that must not leak to clients.\n const authDetails = this.details;\n const details = this.type === ErrorType.Authentication && authDetails?.internal ? undefined : this.details;\n return {\n error: this.type,\n message: this.message,\n status: this.status,\n details,\n };\n }\n /**\n * Construct a `ShipError` from an HTTP error response.\n *\n * Best-effort body parse for `{ message, error?, details? }`. Message\n * resolution: `body.message` → `body.error` → `\"<operationName> failed with\n * status <N>\"`.\n *\n * Type resolution: trusts `body.error` when it's a known server-producible\n * `ErrorType` (preserves the wire's intent — server's\n * `ShipError.validation(...)` round-trips back to `ErrorType.Validation`\n * on the client). Falls back to status-derived (401 → Authentication,\n * 403 → Forbidden, 429 → RateLimit, else → Api) for non-API responses\n * (CDN errors, intermediaries) or malformed bodies. Client-only types\n * (`Network`, `Cancelled`, `File`, `Config`) are filtered out of the\n * trusted set — a misbehaving server claiming one of those is ignored.\n *\n * `operationName` (e.g. `\"Get account\"`) is used to compose the fallback\n * message. Defaults to `\"Request\"`. Same convention as `fromFetchError`.\n *\n * Async because it reads the response body. Returns rather than throws so\n * callers can compose; most will `throw await ShipError.fromHttpResponse(...)`.\n */\n static async fromHttpResponse(response, operationName) {\n let message;\n let details;\n let bodyType;\n try {\n const contentType = response.headers.get('content-type');\n if (contentType?.includes('application/json')) {\n const json = await response.json();\n if (json && typeof json === 'object') {\n const obj = json;\n if (typeof obj.message === 'string')\n message = obj.message;\n else if (typeof obj.error === 'string')\n message = obj.error;\n details = obj.details;\n if (typeof obj.error === 'string' && SERVER_PRODUCIBLE_ERROR_TYPES.has(obj.error)) {\n bodyType = obj.error;\n }\n }\n }\n else {\n const text = await response.text();\n if (text)\n message = text;\n }\n }\n catch {\n // Body unreadable; fall through to operationName-derived message.\n }\n message = message || `${operationName || 'Request'} failed with status ${response.status}`;\n const type = bodyType ??\n (response.status === 401\n ? ErrorType.Authentication\n : response.status === 403\n ? ErrorType.Forbidden\n : response.status === 429\n ? ErrorType.RateLimit\n : ErrorType.Api);\n return new ShipError(type, message, response.status, details);\n }\n /**\n * Construct a `ShipError` from an error caught around a `fetch()` call.\n *\n * The mirror of `fromHttpResponse` for the *other* side of the HTTP error\n * story — the network layer failing (offline, CORS, abort) rather than the\n * server returning a non-OK response.\n *\n * Routing:\n * - Already a `ShipError` → returned as-is (caller's intent preserved)\n * - `AbortError` → `ShipError.cancelled(...)`\n * - `TypeError` whose message mentions \"fetch\" → `ShipError.network(...)`\n * - Any other `Error` → `ShipError(Api, ...)` (no HTTP status — fetch never reached the server)\n * - Anything else (string, undefined, etc.) → `ShipError(Api, ...)`\n *\n * The optional `operationName` is composed into the message for context:\n * `\"Get account was cancelled\"`, `\"Get account failed: ...\"`. Defaults to\n * `\"Request\"` when omitted.\n */\n static fromFetchError(cause, operationName) {\n if (isShipError(cause))\n return cause;\n const op = operationName || 'Request';\n if (cause instanceof Error) {\n if (cause.name === 'AbortError') {\n return ShipError.cancelled(`${op} was cancelled`);\n }\n if (cause instanceof TypeError && cause.message.includes('fetch')) {\n return ShipError.network(`${op} failed: ${cause.message}`, { cause });\n }\n return new ShipError(ErrorType.Api, `${op} failed: ${cause.message}`);\n }\n return new ShipError(ErrorType.Api, `${op} failed: Unknown error`);\n }\n // Factory methods. Uniform shape `(message, details?)` with two principled\n // exceptions: `notFound` composes its message from (resource, id?), and\n // `business` / `api` accept an optional status because they're the\n // multi-status fallbacks.\n static validation(message, details) {\n return new ShipError(ErrorType.Validation, message, 400, details);\n }\n static notFound(resource, id) {\n const message = id ? `${resource} ${id} not found` : `${resource} not found`;\n return new ShipError(ErrorType.NotFound, message, 404);\n }\n static forbidden(message, details) {\n return new ShipError(ErrorType.Forbidden, message, 403, details);\n }\n static rateLimit(message = 'Too many requests', details) {\n return new ShipError(ErrorType.RateLimit, message, 429, details);\n }\n /**\n * Construct an Authentication (401) error.\n *\n * **Telemetry pattern — `details: { internal: '<tag>' }`.** When the\n * server creates an auth error with an `internal` key in `details`\n * (e.g. `{ internal: 'session_invalid' }`), `toResponse()` strips the\n * entire `details` object before serialization. This keeps the wire\n * response a clean \"Authentication failed\" while preserving granular\n * server-side telemetry (which strategy/check failed) for logs and tests.\n *\n * Use this pattern in API auth code; do not put client-visible info under\n * `internal`. Other `details` keys round-trip normally.\n */\n static authentication(message = 'Authentication required', details) {\n return new ShipError(ErrorType.Authentication, message, 401, details);\n }\n static business(message, status = 400, details) {\n return new ShipError(ErrorType.Business, message, status, details);\n }\n static network(message, details) {\n return new ShipError(ErrorType.Network, message, undefined, details);\n }\n static cancelled(message, details) {\n return new ShipError(ErrorType.Cancelled, message, undefined, details);\n }\n static file(message, details) {\n return new ShipError(ErrorType.File, message, undefined, details);\n }\n static config(message, details) {\n return new ShipError(ErrorType.Config, message, undefined, details);\n }\n static api(message, status = 500, details) {\n return new ShipError(ErrorType.Api, message, status, details);\n }\n // Semantic-category type guards. For specific-type checks, use\n // `error.type === ErrorType.X` directly or the generic `isType(t)`.\n isClientError() {\n return ERROR_CATEGORIES.client.has(this.type);\n }\n isNetworkError() {\n return ERROR_CATEGORIES.network.has(this.type);\n }\n isAuthError() {\n return ERROR_CATEGORIES.auth.has(this.type);\n }\n isType(errorType) {\n return this.type === errorType;\n }\n}\n/**\n * Type guard to check if an unknown value is a ShipError.\n *\n * Uses structural checking instead of instanceof to handle module duplication\n * in bundled applications where multiple copies of the ShipError class may exist.\n *\n * @example\n * if (isShipError(error)) {\n * console.log(error.status, error.message);\n * }\n */\nexport function isShipError(error) {\n return (error !== null &&\n typeof error === 'object' &&\n 'name' in error &&\n error.name === 'ShipError' &&\n 'status' in error);\n}\n// =============================================================================\n// EXTENSION BLOCKLIST\n// =============================================================================\n/**\n * Blocked file extensions — files that cannot be uploaded.\n *\n * We accept any file type by default and derive Content-Type from the\n * extension at serve time (via mime-db in the API worker). Unknown extensions\n * are served as `application/octet-stream` with `X-Content-Type-Options: nosniff`.\n *\n * The blocklist targets file types that pose direct security risks when hosted:\n * executables, disk images, malware vectors, dangerous scripts, and shortcuts.\n */\nexport const BLOCKED_EXTENSIONS = new Set([\n // Executables\n 'exe',\n 'msi',\n 'dll',\n 'scr',\n 'bat',\n 'cmd',\n 'com',\n 'pif',\n 'app',\n 'deb',\n 'rpm',\n // Installers\n 'pkg',\n 'mpkg',\n // Disk images\n 'dmg',\n 'iso',\n 'img',\n // Malware vectors\n 'cab',\n 'cpl',\n 'chm',\n // Dangerous scripts\n 'ps1',\n 'vbs',\n 'vbe',\n 'ws',\n 'wsf',\n 'wsc',\n 'wsh',\n 'reg',\n // Java\n 'jar',\n 'jnlp',\n // Mobile/browser packages\n 'apk',\n 'crx',\n // Shortcut/link\n 'lnk',\n 'inf',\n 'hta',\n]);\n/**\n * Check if a filename has a blocked extension.\n * Extracts the extension from the filename and checks against the blocklist.\n * Case-insensitive. Returns false for files without extensions.\n *\n * @example\n * isBlockedExtension('virus.exe') // true\n * isBlockedExtension('app.dmg') // true\n * isBlockedExtension('style.css') // false\n * isBlockedExtension('data.custom') // false\n * isBlockedExtension('README') // false\n */\nexport function isBlockedExtension(filename) {\n const dotIndex = filename.lastIndexOf('.');\n if (dotIndex === -1 || dotIndex === filename.length - 1)\n return false;\n const ext = filename.slice(dotIndex + 1).toLowerCase();\n return BLOCKED_EXTENSIONS.has(ext);\n}\n// =============================================================================\n// FILENAME CHARACTER VALIDATION\n// =============================================================================\n/**\n * Characters that are unsafe in filenames for static hosting.\n *\n * Blocks only characters that genuinely break the upload→serve round-trip:\n * - # ? % URL round-trip breakers (fragment, query, encoding ambiguity)\n * - \\ Path separator confusion (upload splits on backslash)\n * - < > \" XSS vectors with zero legitimate use in filenames\n * - \\x00-\\x1f \\x7f Control characters (header injection, display corruption)\n *\n * Everything else is allowed — browser percent-encodes, Worker decodes, R2 matches.\n */\n// biome-ignore lint/suspicious/noControlCharactersInRegex: blocking control characters is this regex's purpose\nexport const UNSAFE_FILENAME_CHARS = /[\\x00-\\x1f\\x7f#?%\\\\<>\"]/;\n/**\n * Check if a filename contains unsafe characters.\n *\n * @example\n * hasUnsafeChars('saved_resource(1).html') // false — parentheses are safe\n * hasUnsafeChars('page[slug].js') // false — brackets are safe\n * hasUnsafeChars('file#anchor.html') // true — # breaks URL resolution\n * hasUnsafeChars('file<tag>.html') // true — < is an XSS vector\n */\nexport function hasUnsafeChars(filename) {\n return UNSAFE_FILENAME_CHARS.test(filename);\n}\n// =============================================================================\n// UNBUILT PROJECT MARKERS\n// =============================================================================\n/**\n * Path segment names that indicate an unbuilt project was uploaded instead of build output.\n * Used for early detection in CLI, browser, and server validation.\n */\nexport const UNBUILT_PROJECT_MARKERS = new Set([\n 'node_modules',\n 'package.json',\n]);\n/**\n * Check if a file path contains an unbuilt project marker.\n *\n * @example\n * hasUnbuiltMarker('node_modules/react/index.js') // true\n * hasUnbuiltMarker('package.json') // true\n * hasUnbuiltMarker('dist/index.html') // false\n */\nexport function hasUnbuiltMarker(filePath) {\n const segments = filePath.replace(/\\\\/g, '/').split('/').filter(Boolean);\n return segments.some((s) => UNBUILT_PROJECT_MARKERS.has(s));\n}\n// =============================================================================\n// CREDENTIAL SHAPES\n// =============================================================================\n// The one address for credential vocabulary: how a request is authorized\n// (AuthMethod), the shapes that distinguish populations on the wire\n// (API_KEY, DEPLOY_TOKEN, CALLER), the single dispatch over them (TokenKind,\n// classifyToken), and the delegated-access scopes (OAuthScope).\n/**\n * How a request (or recorded activity) was authorized.\n *\n * Client populations: `SESSION` (first-party cookie), `API_KEY` (`ship-`\n * key), `TOKEN` (`deploy-` deploy token), `AGENT` (anonymous public deploy —\n * no credential; the platform grants the public-account identity per\n * request), `OAUTH` (delegated access token). Server populations: `WEBHOOK`\n * (signed webhook processing), `SYSTEM` (scheduled/background jobs).\n */\nexport const AuthMethod = {\n SESSION: 'session',\n API_KEY: 'apiKey',\n TOKEN: 'token',\n AGENT: 'agent',\n OAUTH: 'oauth',\n WEBHOOK: 'webhook',\n SYSTEM: 'system',\n};\n/**\n * Shape constants for API keys (`ship-{64 hex chars}`).\n * Single source of truth used by validation utilities and auth middleware.\n */\nexport const API_KEY = {\n /** Prefix that identifies an API key. */\n PREFIX: 'ship-',\n /** Number of hex characters following the prefix. */\n HEX_LENGTH: 64,\n /** Total length of an API key including prefix (`PREFIX.length + HEX_LENGTH = 69`). */\n TOTAL_LENGTH: 69,\n /** Number of trailing characters used to display a redacted hint (e.g. last 4). */\n HINT_LENGTH: 4,\n};\n/**\n * Shape constants for deploy tokens (`deploy-{64 hex chars}`).\n * Single source of truth used by validation utilities and auth middleware.\n */\nexport const DEPLOY_TOKEN = {\n /** Prefix that identifies a deploy token. */\n PREFIX: 'deploy-',\n /** Number of hex characters following the prefix. */\n HEX_LENGTH: 64,\n /** Total length of a deploy token including prefix (`PREFIX.length + HEX_LENGTH = 71`). */\n TOTAL_LENGTH: 71,\n};\n/**\n * Shape constants for caller identifiers (the `X-Caller` instance-identity\n * header — rate-limit bucketing for multi-tenant orchestrators). The API\n * normalizes case and silently ignores malformed values (the header is\n * unauthenticated); clients validate at the boundary via `validateCaller`,\n * so a value the server would drop fails fast instead.\n */\nexport const CALLER = {\n /** HTTP header name. */\n HEADER: 'X-Caller',\n /** Maximum identifier length. */\n MAX_LENGTH: 128,\n /** Allowed characters: alphanumeric, dot, underscore, hyphen. */\n PATTERN: /^[a-zA-Z0-9._-]+$/,\n};\n/**\n * Token populations distinguishable by shape. The platform carries every\n * client token in one wire slot (`Authorization: Bearer <value>`) and\n * classifies by value, never by a side channel — this is the classifier.\n *\n * `API_KEY` and `DEPLOY_TOKEN` *are* `AuthMethod.API_KEY` and\n * `AuthMethod.TOKEN` — the equality is structural, so a classification flows\n * straight into an auth method and the pair can never drift. `OPAQUE` is any\n * other value — shape says nothing about it, so only a lookup can. Today the\n * server refuses every opaque bearer; the OAuth access-token population\n * resolves there when the authorization server ships.\n */\nexport const TokenKind = {\n API_KEY: AuthMethod.API_KEY,\n DEPLOY_TOKEN: AuthMethod.TOKEN,\n OPAQUE: 'opaque',\n};\n/**\n * Classify a client token by shape. The single dispatch used by both sides\n * of the wire: API auth middleware (which population is this credential?)\n * and SDK validation (which format rules apply before sending?). Sharing it\n * is what guarantees client and server can never disagree on dispatch.\n */\nexport function classifyToken(token) {\n if (token.startsWith(API_KEY.PREFIX))\n return TokenKind.API_KEY;\n if (token.startsWith(DEPLOY_TOKEN.PREFIX))\n return TokenKind.DEPLOY_TOKEN;\n return TokenKind.OPAQUE;\n}\n/**\n * OAuth scope vocabulary for delegated third-party access tokens.\n * Single source of truth used by the authorization server (advertised in\n * `scopes_supported`), the API's scope-enforcement middleware, and consent UI\n * copy. The standard `offline_access` scope (refresh tokens) is not platform\n * vocabulary and is deliberately absent — the middleware never checks it.\n *\n * Deliberately absent by design: any `tokens:*` scope, `account:write`, or\n * admin scope — a delegated app must never mint credentials, delete the\n * account, or act as admin.\n */\nexport const OAuthScope = {\n ACCOUNT_READ: 'account:read',\n DEPLOYMENTS_READ: 'deployments:read',\n DEPLOYMENTS_WRITE: 'deployments:write',\n DOMAINS_READ: 'domains:read',\n DOMAINS_WRITE: 'domains:write',\n};\n// =============================================================================\n// DEPLOYMENT CONFIGURATION CONSTANTS\n// =============================================================================\nexport const DEPLOYMENT_CONFIG_FILENAME = 'ship.json';\n/** Default ship.json config for SPA routing. Single source of truth — used by both API and SDK. */\nexport const SPA_DEFAULT_CONFIG = {\n rewrites: [{ source: '/(.*)', destination: '/index.html' }],\n};\n// =============================================================================\n// VALIDATION UTILITIES\n// =============================================================================\n/**\n * Shared rule for prefixed credentials: `{PREFIX}{HEX_LENGTH hex chars}`.\n * The regex derives from the shape constants, so the validators can never\n * drift from the shapes `classifyToken` dispatches on.\n */\nfunction validatePrefixedCredential(value, shape, label) {\n if (!value.startsWith(shape.PREFIX)) {\n throw ShipError.validation(`${label} must start with \"${shape.PREFIX}\"`);\n }\n if (value.length !== shape.TOTAL_LENGTH) {\n throw ShipError.validation(`${label} must be ${shape.TOTAL_LENGTH} characters total (${shape.PREFIX} + ${shape.HEX_LENGTH} hex chars)`);\n }\n const hexPart = value.slice(shape.PREFIX.length);\n if (!new RegExp(`^[a-f0-9]{${shape.HEX_LENGTH}}$`, 'i').test(hexPart)) {\n throw ShipError.validation(`${label} must contain ${shape.HEX_LENGTH} hexadecimal characters after \"${shape.PREFIX}\" prefix`);\n }\n}\n/**\n * Validate API key format\n */\nexport function validateApiKey(apiKey) {\n validatePrefixedCredential(apiKey, API_KEY, 'API key');\n}\n/**\n * Validate deploy token format\n */\nexport function validateDeployToken(deployToken) {\n validatePrefixedCredential(deployToken, DEPLOY_TOKEN, 'Deploy token');\n}\n/**\n * Validate a client token of any population. Classifies by shape and applies\n * the matching format rules: `ship-` keys and `deploy-` deploy tokens are\n * validated strictly; opaque tokens (OAuth access tokens, future populations)\n * only need to be non-empty — their validity is the server's to decide.\n */\nexport function validateToken(token) {\n switch (classifyToken(token)) {\n case TokenKind.API_KEY:\n validateApiKey(token);\n return;\n case TokenKind.DEPLOY_TOKEN:\n validateDeployToken(token);\n return;\n case TokenKind.OPAQUE:\n if (!token)\n throw ShipError.validation('Token must be a non-empty string');\n }\n}\n/**\n * Validate a caller identifier against the `CALLER` shape. The server\n * silently ignores malformed values (the header is unauthenticated); clients\n * call this at configuration time so the drop never silently happens.\n */\nexport function validateCaller(caller) {\n if (!caller || caller.length > CALLER.MAX_LENGTH || !CALLER.PATTERN.test(caller)) {\n throw ShipError.validation(`Caller must be 1-${CALLER.MAX_LENGTH} characters: letters, digits, dots, underscores, or hyphens`);\n }\n}\n/**\n * Validate API URL format\n */\nexport function validateApiUrl(apiUrl) {\n try {\n const url = new URL(apiUrl);\n if (!['http:', 'https:'].includes(url.protocol)) {\n throw ShipError.validation('API URL must use http:// or https:// protocol');\n }\n if (url.pathname !== '/' && url.pathname !== '') {\n throw ShipError.validation('API URL must not contain a path');\n }\n if (url.search || url.hash) {\n throw ShipError.validation('API URL must not contain query parameters or fragments');\n }\n }\n catch (error) {\n if (isShipError(error)) {\n throw error;\n }\n throw ShipError.validation('API URL must be a valid URL');\n }\n}\n/**\n * Check if a string matches the deployment identifier pattern (word-word-alphanumeric7).\n * Example: \"happy-cat-abc1234.shipstatic.com\"\n */\nexport function isDeployment(input) {\n return /^[a-z]+-[a-z]+-[a-z0-9]{7}(\\.[a-z0-9.-]+)?$/i.test(input);\n}\n// =============================================================================\n// PLATFORM CONSTANTS\n// =============================================================================\n/** Default API URL if not otherwise configured. */\nexport const DEFAULT_API = 'https://api.shipstatic.com';\n// =============================================================================\n// FILE UPLOAD TYPES\n// =============================================================================\n/**\n * File status constants for validation state tracking\n */\nexport const FileValidationStatus = {\n /** File is pending validation */\n PENDING: 'pending',\n /** File failed during processing (before validation) */\n PROCESSING_ERROR: 'processing_error',\n /** File was excluded by validation warning (not an error) */\n EXCLUDED: 'excluded',\n /** File failed validation (blocks deployment) */\n VALIDATION_FAILED: 'validation_failed',\n /** File passed validation and is ready for deployment */\n READY: 'ready',\n};\n// =============================================================================\n// DOMAIN UTILITIES\n// =============================================================================\n/**\n * Check if a domain is a platform domain (subdomain of our platform).\n * Platform domains are free and don't require DNS verification.\n *\n * @example isPlatformDomain(\"www.shipstatic.com\", \"shipstatic.com\") → true\n * @example isPlatformDomain(\"example.com\", \"shipstatic.com\") → false\n */\nexport function isPlatformDomain(domain, platformDomain) {\n return domain.endsWith(`.${platformDomain}`);\n}\n/**\n * Check if a domain is a custom domain (not a platform subdomain).\n * Custom domains are billable and require DNS verification.\n *\n * @example isCustomDomain(\"example.com\", \"shipstatic.com\") → true\n * @example isCustomDomain(\"www.shipstatic.com\", \"shipstatic.com\") → false\n */\nexport function isCustomDomain(domain, platformDomain) {\n return !isPlatformDomain(domain, platformDomain);\n}\n/**\n * Extract subdomain from a platform domain.\n * Returns null if not a platform domain.\n *\n * @example extractSubdomain(\"www.shipstatic.com\", \"shipstatic.com\") → \"www\"\n * @example extractSubdomain(\"example.com\", \"shipstatic.com\") → null\n */\nexport function extractSubdomain(domain, platformDomain) {\n if (!isPlatformDomain(domain, platformDomain)) {\n return null;\n }\n return domain.slice(0, -(platformDomain.length + 1)); // +1 for the dot\n}\n/**\n * Generate HTTPS URL for a deployment hostname.\n */\nexport function generateDeploymentUrl(deployment) {\n return `https://${deployment}`;\n}\n/**\n * Generate HTTPS URL for a domain.\n */\nexport function generateDomainUrl(domain) {\n return `https://${domain}`;\n}\n// =============================================================================\n// LABEL UTILITIES\n// =============================================================================\n/**\n * Label validation constraints shared across UI and API.\n * These rules define the single source of truth for label validation.\n */\nexport const LABEL_CONSTRAINTS = {\n /** Minimum label length in characters */\n MIN_LENGTH: 3,\n /** Maximum label length in characters (concise labels, matches Stack Overflow's original limit) */\n MAX_LENGTH: 25,\n /** Maximum number of labels allowed per resource */\n MAX_COUNT: 10,\n /** Allowed separator characters between label segments */\n SEPARATORS: '._-',\n};\n/**\n * Label validation pattern.\n * Must start and end with alphanumeric (a-z, 0-9).\n * Can contain separators (. _ -) between segments, but not consecutive.\n *\n * Valid examples: 'production', 'v1.2.3', 'api_v2', 'us-east-1'\n * Invalid examples: 'ab' (too short), '-prod' (starts with separator), 'foo--bar' (consecutive separators)\n */\nexport const LABEL_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;\n/**\n * Serialize labels array to JSON string for database storage.\n * Returns null for empty or undefined arrays.\n *\n * @example serializeLabels(['web', 'production']) → '[\"web\",\"production\"]'\n * @example serializeLabels([]) → null\n * @example serializeLabels(undefined) → null\n */\nexport function serializeLabels(labels) {\n if (!labels || labels.length === 0)\n return null;\n return JSON.stringify(labels);\n}\n/**\n * Deserialize labels from JSON string to array.\n * Always returns an array — empty array for null/empty/invalid input.\n *\n * @example deserializeLabels('[\"web\",\"production\"]') → ['web', 'production']\n * @example deserializeLabels(null) → []\n * @example deserializeLabels('') → []\n */\nexport function deserializeLabels(labelsJson) {\n if (!labelsJson)\n return [];\n try {\n const parsed = JSON.parse(labelsJson);\n return Array.isArray(parsed) ? parsed : [];\n }\n catch {\n return [];\n }\n}\n// =============================================================================\n// PASSWORD UTILITIES\n// =============================================================================\n/**\n * Length constraints for the optional deployment password\n * (`DeploymentUploadOptions.password`). Single source of truth shared across\n * platform consumers.\n */\nexport const PASSWORD_CONSTRAINTS = {\n /** Minimum password length in characters */\n MIN_LENGTH: 6,\n /** Maximum password length in characters */\n MAX_LENGTH: 128,\n};\n/**\n * Validate an optional deployment password and return it normalized.\n *\n * Absent (`undefined` / `null`) → returns `undefined`. Present → trim\n * leading/trailing whitespace, then validate against `PASSWORD_CONSTRAINTS`\n * length bounds (internal whitespace is significant and counts toward\n * length). Throws `ShipError.validation` on breach; returns the trimmed\n * value.\n *\n * The trim is canonical: at upload, the API hashes the trimmed value; at\n * unlock, the router trims submissions before hashing. Submission and storage\n * agree byte-for-byte. Length validation runs on the trimmed value because\n * that's the user's actual intent — and it disarms a class of invisible\n * foot-guns (trailing newlines from copy/paste, mobile auto-spacing,\n * password-manager artifacts).\n *\n * Single source of truth shared by SDK (client-side validation, return\n * ignored) and API (server-side enforcement, return threaded into config).\n * Length is part of the wire-format contract; strength rules, if added later,\n * stay server-side. See `CLAUDE.md` \"Validation: format vs policy\".\n */\nexport function validatePassword(value) {\n if (value === undefined || value === null)\n return undefined;\n if (typeof value !== 'string') {\n throw ShipError.validation('Password must be a string');\n }\n const trimmed = value.trim();\n if (trimmed.length < PASSWORD_CONSTRAINTS.MIN_LENGTH ||\n trimmed.length > PASSWORD_CONSTRAINTS.MAX_LENGTH) {\n throw ShipError.validation(`Password must be between ${PASSWORD_CONSTRAINTS.MIN_LENGTH} and ${PASSWORD_CONSTRAINTS.MAX_LENGTH} characters`);\n }\n return trimmed;\n}\n","/**\n * Test utilities for consumers of `useDrop`.\n *\n * ```typescript\n * import { createMockDrop } from '@shipstatic/drop/testing';\n * ```\n *\n * The whole subpath exists for one reason: a `DropReturn` has twenty fields, and\n * a component test that takes `drop` as a prop should not have to build them.\n * Everything else — spying, matching, asserting — belongs to your test framework,\n * so this file deliberately ships none of it.\n */\n\nimport { FileValidationStatus, type FileValidationStatusType } from '@shipstatic/types';\nimport type { ProcessedFile } from './types';\nimport type { DropReturn, DropzonePropsOptions } from './useDrop';\n\nconst noop = () => {};\n\n/**\n * Build a `DropReturn` for rendering tests.\n *\n * Any field can be overridden, including with your own spies — which is how you\n * assert on interactions:\n *\n * ```tsx\n * const reset = vi.fn();\n * const drop = createMockDrop({ phase: 'ready', files: [...], reset });\n *\n * render(<DeployDropArea drop={drop} />);\n * await userEvent.click(screen.getByText('Clear'));\n *\n * expect(reset).toHaveBeenCalled();\n * ```\n *\n * The convenience booleans (`isProcessing`, `hasError`, `isInteractive`) and\n * `validFiles` are derived from `phase` and `files` unless you override them, so\n * the mock can never present a state the real hook could not reach by accident.\n */\nexport function createMockDrop(overrides: Partial<DropReturn> = {}): DropReturn {\n const phase = overrides.phase ?? 'idle';\n const files = overrides.files ?? [];\n const validFiles =\n overrides.validFiles ?? files.filter((f) => f.status === FileValidationStatus.READY);\n\n return {\n phase,\n isProcessing: phase === 'processing',\n isDragging: false,\n isInteractive: phase === 'idle' || phase === 'ready',\n hasError: phase === 'error',\n files,\n sourceName: '',\n status: null,\n needsBuild: false,\n\n getDropzoneProps: (options?: DropzonePropsOptions) => ({\n onDragOver: noop,\n onDragLeave: noop,\n onDrop: noop,\n ...(options?.clickable !== false && { onClick: noop }),\n }),\n getInputProps: () => ({\n ref: { current: null },\n type: 'file' as const,\n style: { display: 'none' },\n multiple: true,\n webkitdirectory: '',\n onChange: noop,\n }),\n\n open: noop,\n processFiles: async () => {},\n reset: noop,\n\n validFiles,\n getFilesForUpload: () => validFiles.map((f) => f.file),\n\n // Explicit values win over every derivation above.\n ...overrides,\n };\n}\n\n/**\n * A `useDrop` replacement for consumers that call the hook rather than receiving\n * `drop` as a prop.\n *\n * ```tsx\n * vi.mock('@shipstatic/drop', () => ({ useDrop: mockUseDrop({ phase: 'ready' }) }));\n * ```\n *\n * Framework-agnostic on purpose — it returns a function, and your test framework\n * installs it. The value is not the three lines it saves: it is that the mock's\n * shape comes from `createMockDrop`, so it cannot describe a hook this package\n * does not have. A hand-written module mock can, and did — one consumer described\n * react-dropzone's API (`rejectedFiles`, `isDragActive`, `getRootProps`, `clear`)\n * for months, because nothing typechecked it.\n *\n * Note this replaces the WHOLE module. If you also import `processFiles` or a\n * type from `@shipstatic/drop`, spread the real module in first:\n *\n * ```tsx\n * vi.mock('@shipstatic/drop', async (importOriginal) => ({\n * ...(await importOriginal<typeof import('@shipstatic/drop')>()),\n * useDrop: mockUseDrop({ phase: 'ready' }),\n * }));\n * ```\n */\nexport function mockUseDrop(overrides: Partial<DropReturn> = {}): () => DropReturn {\n return () => createMockDrop(overrides);\n}\n\nlet mockFileIdCounter = 0;\n\n/** Build a `ProcessedFile` backed by a real `File`. */\nexport function createMockProcessedFile(\n name: string,\n options: {\n path?: string;\n content?: string;\n type?: string;\n status?: FileValidationStatusType;\n statusMessage?: string;\n } = {},\n): ProcessedFile {\n const {\n path = name,\n content = 'test content',\n type = 'text/plain',\n status = FileValidationStatus.READY,\n statusMessage,\n } = options;\n\n const file = new File([content], name, { type });\n\n return {\n id: `mock-file-${++mockFileIdCounter}`,\n file,\n path,\n name,\n size: file.size,\n type,\n lastModified: file.lastModified,\n status,\n statusMessage,\n };\n}\n\n/**\n * Build a real `File` carrying a folder-relative path, the way a browser\n * presents a folder drop. (`webkitRelativePath` is read-only, hence the\n * redefinition — the one part of this that is not a one-liner.)\n */\nexport function createMockFileWithPath(\n name: string,\n webkitRelativePath: string,\n content = 'test content',\n type = 'text/plain',\n): File {\n const file = new File([content], name, { type });\n Object.defineProperty(file, 'webkitRelativePath', {\n value: webkitRelativePath,\n writable: false,\n enumerable: true,\n configurable: true,\n });\n return file;\n}\n"]}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Ship } from '@shipstatic/ship';
|
|
2
|
+
import { FileValidationStatusType } from '@shipstatic/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Core types for @shipstatic/drop.
|
|
6
|
+
*
|
|
7
|
+
* `@shipstatic/types` owns the file-validation vocabulary and drop uses it by its
|
|
8
|
+
* real name — `FileValidationStatus`, reachable from `@shipstatic/ship`, which
|
|
9
|
+
* every consumer already depends on. Re-badging it as a drop-local
|
|
10
|
+
* `FILE_STATUSES` would put a second name on one object and leave a consumer of
|
|
11
|
+
* both packages wondering whether they differ.
|
|
12
|
+
*
|
|
13
|
+
* Drop adds no statuses of its own, so `ProcessedFile` stays expressible as a
|
|
14
|
+
* `ValidatableFile` and the packages can never disagree about what "ready" means.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A file prepared for deployment.
|
|
19
|
+
*
|
|
20
|
+
* `path` is the deploy identity; `name` is the basename, for display. Ship
|
|
21
|
+
* computes checksums during upload, so no `md5` lives here.
|
|
22
|
+
*/
|
|
23
|
+
interface ProcessedFile {
|
|
24
|
+
/** Unique identifier for React keys and tracking */
|
|
25
|
+
id: string;
|
|
26
|
+
/** The File object — pass this to ship.deployments.upload() */
|
|
27
|
+
file: File;
|
|
28
|
+
/** Relative path for deployment (e.g. "images/photo.jpg") */
|
|
29
|
+
path: string;
|
|
30
|
+
/** File size in bytes */
|
|
31
|
+
size: number;
|
|
32
|
+
/** Filename without path — for display; `path` is the deploy identity */
|
|
33
|
+
name: string;
|
|
34
|
+
/** MIME type as reported by the browser, for UI icons/previews */
|
|
35
|
+
type: string;
|
|
36
|
+
/** Last modified timestamp */
|
|
37
|
+
lastModified: number;
|
|
38
|
+
/** Current processing status */
|
|
39
|
+
status: FileValidationStatusType;
|
|
40
|
+
/** Human-readable status message for UI */
|
|
41
|
+
statusMessage?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Phase of the drop lifecycle.
|
|
45
|
+
*
|
|
46
|
+
* Dragging is not a phase — it is a pointer state that can occur over any of
|
|
47
|
+
* these, carried separately as `isDragging`.
|
|
48
|
+
*/
|
|
49
|
+
type DropPhase = 'idle' | 'processing' | 'ready' | 'error';
|
|
50
|
+
/** What to show the user about the current phase. */
|
|
51
|
+
interface DropStatus {
|
|
52
|
+
title: string;
|
|
53
|
+
details: string;
|
|
54
|
+
/** Per-item breakdown, for multi-error cases */
|
|
55
|
+
errors?: string[];
|
|
56
|
+
/** Non-blocking issues (e.g. excluded empty files) */
|
|
57
|
+
warnings?: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Headless drop hook for file upload workflows.
|
|
62
|
+
*
|
|
63
|
+
* This file owns React state, DOM events, and prop getters — nothing else. The
|
|
64
|
+
* rules live in `./process` as a plain function.
|
|
65
|
+
*
|
|
66
|
+
* ```tsx
|
|
67
|
+
* const drop = useDrop({ ship });
|
|
68
|
+
*
|
|
69
|
+
* <div {...drop.getDropzoneProps()}>
|
|
70
|
+
* <input {...drop.getInputProps()} />
|
|
71
|
+
* {drop.isDragging ? 'Drop here' : 'Click to upload'}
|
|
72
|
+
* </div>
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
interface DropOptions {
|
|
77
|
+
/**
|
|
78
|
+
* The Ship client, for platform limits.
|
|
79
|
+
*
|
|
80
|
+
* Typed as only what drop calls, mirroring the SDK's own resource-factory
|
|
81
|
+
* doctrine — a real `Ship` satisfies it, and nothing else has to be faked.
|
|
82
|
+
*/
|
|
83
|
+
ship: Pick<Ship, 'getLimits'>;
|
|
84
|
+
}
|
|
85
|
+
/** Options for `getDropzoneProps()` */
|
|
86
|
+
interface DropzonePropsOptions {
|
|
87
|
+
/** Whether clicking the dropzone opens the file picker (default: true) */
|
|
88
|
+
clickable?: boolean;
|
|
89
|
+
}
|
|
90
|
+
interface DropReturn {
|
|
91
|
+
/** Current phase of the lifecycle */
|
|
92
|
+
phase: DropPhase;
|
|
93
|
+
/** Whether files are being processed (extraction, validation) */
|
|
94
|
+
isProcessing: boolean;
|
|
95
|
+
/** Whether the user is currently dragging over the dropzone */
|
|
96
|
+
isDragging: boolean;
|
|
97
|
+
/** Whether the dropzone is idle or holding a ready set */
|
|
98
|
+
isInteractive: boolean;
|
|
99
|
+
/** Whether an error occurred during processing */
|
|
100
|
+
hasError: boolean;
|
|
101
|
+
/** All processed files */
|
|
102
|
+
files: ProcessedFile[];
|
|
103
|
+
/** Friendly name of what was dropped (ZIP name, folder name, or filename) */
|
|
104
|
+
sourceName: string;
|
|
105
|
+
/** Current status for display */
|
|
106
|
+
status: DropStatus | null;
|
|
107
|
+
/** Whether the dropped files need server-side building before deployment */
|
|
108
|
+
needsBuild: boolean;
|
|
109
|
+
/** Props to spread on the dropzone element (drag & drop, optionally click) */
|
|
110
|
+
getDropzoneProps: (options?: DropzonePropsOptions) => {
|
|
111
|
+
onDragOver: (e: React.DragEvent) => void;
|
|
112
|
+
onDragLeave: (e: React.DragEvent) => void;
|
|
113
|
+
onDrop: (e: React.DragEvent) => void;
|
|
114
|
+
onClick?: () => void;
|
|
115
|
+
};
|
|
116
|
+
/** Props to spread on the hidden file input element */
|
|
117
|
+
getInputProps: () => {
|
|
118
|
+
ref: React.RefObject<HTMLInputElement | null>;
|
|
119
|
+
type: 'file';
|
|
120
|
+
style: {
|
|
121
|
+
display: string;
|
|
122
|
+
};
|
|
123
|
+
multiple: boolean;
|
|
124
|
+
webkitdirectory: string;
|
|
125
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
126
|
+
};
|
|
127
|
+
/** Programmatically trigger the file picker */
|
|
128
|
+
open: () => void;
|
|
129
|
+
/** Process files directly (advanced — loses folder traversal) */
|
|
130
|
+
processFiles: (files: File[]) => Promise<void>;
|
|
131
|
+
/** Reset state and clear all files */
|
|
132
|
+
reset: () => void;
|
|
133
|
+
/** Only the files that passed validation */
|
|
134
|
+
validFiles: ProcessedFile[];
|
|
135
|
+
/** Raw File objects ready for Ship SDK upload */
|
|
136
|
+
getFilesForUpload: () => File[];
|
|
137
|
+
}
|
|
138
|
+
declare function useDrop({ ship }: DropOptions): DropReturn;
|
|
139
|
+
|
|
140
|
+
export { type DropStatus as D, type ProcessedFile as P, type DropOptions as a, type DropPhase as b, type DropReturn as c, type DropzonePropsOptions as d, useDrop as u };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Ship } from '@shipstatic/ship';
|
|
2
|
+
import { FileValidationStatusType } from '@shipstatic/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Core types for @shipstatic/drop.
|
|
6
|
+
*
|
|
7
|
+
* `@shipstatic/types` owns the file-validation vocabulary and drop uses it by its
|
|
8
|
+
* real name — `FileValidationStatus`, reachable from `@shipstatic/ship`, which
|
|
9
|
+
* every consumer already depends on. Re-badging it as a drop-local
|
|
10
|
+
* `FILE_STATUSES` would put a second name on one object and leave a consumer of
|
|
11
|
+
* both packages wondering whether they differ.
|
|
12
|
+
*
|
|
13
|
+
* Drop adds no statuses of its own, so `ProcessedFile` stays expressible as a
|
|
14
|
+
* `ValidatableFile` and the packages can never disagree about what "ready" means.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A file prepared for deployment.
|
|
19
|
+
*
|
|
20
|
+
* `path` is the deploy identity; `name` is the basename, for display. Ship
|
|
21
|
+
* computes checksums during upload, so no `md5` lives here.
|
|
22
|
+
*/
|
|
23
|
+
interface ProcessedFile {
|
|
24
|
+
/** Unique identifier for React keys and tracking */
|
|
25
|
+
id: string;
|
|
26
|
+
/** The File object — pass this to ship.deployments.upload() */
|
|
27
|
+
file: File;
|
|
28
|
+
/** Relative path for deployment (e.g. "images/photo.jpg") */
|
|
29
|
+
path: string;
|
|
30
|
+
/** File size in bytes */
|
|
31
|
+
size: number;
|
|
32
|
+
/** Filename without path — for display; `path` is the deploy identity */
|
|
33
|
+
name: string;
|
|
34
|
+
/** MIME type as reported by the browser, for UI icons/previews */
|
|
35
|
+
type: string;
|
|
36
|
+
/** Last modified timestamp */
|
|
37
|
+
lastModified: number;
|
|
38
|
+
/** Current processing status */
|
|
39
|
+
status: FileValidationStatusType;
|
|
40
|
+
/** Human-readable status message for UI */
|
|
41
|
+
statusMessage?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Phase of the drop lifecycle.
|
|
45
|
+
*
|
|
46
|
+
* Dragging is not a phase — it is a pointer state that can occur over any of
|
|
47
|
+
* these, carried separately as `isDragging`.
|
|
48
|
+
*/
|
|
49
|
+
type DropPhase = 'idle' | 'processing' | 'ready' | 'error';
|
|
50
|
+
/** What to show the user about the current phase. */
|
|
51
|
+
interface DropStatus {
|
|
52
|
+
title: string;
|
|
53
|
+
details: string;
|
|
54
|
+
/** Per-item breakdown, for multi-error cases */
|
|
55
|
+
errors?: string[];
|
|
56
|
+
/** Non-blocking issues (e.g. excluded empty files) */
|
|
57
|
+
warnings?: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Headless drop hook for file upload workflows.
|
|
62
|
+
*
|
|
63
|
+
* This file owns React state, DOM events, and prop getters — nothing else. The
|
|
64
|
+
* rules live in `./process` as a plain function.
|
|
65
|
+
*
|
|
66
|
+
* ```tsx
|
|
67
|
+
* const drop = useDrop({ ship });
|
|
68
|
+
*
|
|
69
|
+
* <div {...drop.getDropzoneProps()}>
|
|
70
|
+
* <input {...drop.getInputProps()} />
|
|
71
|
+
* {drop.isDragging ? 'Drop here' : 'Click to upload'}
|
|
72
|
+
* </div>
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
interface DropOptions {
|
|
77
|
+
/**
|
|
78
|
+
* The Ship client, for platform limits.
|
|
79
|
+
*
|
|
80
|
+
* Typed as only what drop calls, mirroring the SDK's own resource-factory
|
|
81
|
+
* doctrine — a real `Ship` satisfies it, and nothing else has to be faked.
|
|
82
|
+
*/
|
|
83
|
+
ship: Pick<Ship, 'getLimits'>;
|
|
84
|
+
}
|
|
85
|
+
/** Options for `getDropzoneProps()` */
|
|
86
|
+
interface DropzonePropsOptions {
|
|
87
|
+
/** Whether clicking the dropzone opens the file picker (default: true) */
|
|
88
|
+
clickable?: boolean;
|
|
89
|
+
}
|
|
90
|
+
interface DropReturn {
|
|
91
|
+
/** Current phase of the lifecycle */
|
|
92
|
+
phase: DropPhase;
|
|
93
|
+
/** Whether files are being processed (extraction, validation) */
|
|
94
|
+
isProcessing: boolean;
|
|
95
|
+
/** Whether the user is currently dragging over the dropzone */
|
|
96
|
+
isDragging: boolean;
|
|
97
|
+
/** Whether the dropzone is idle or holding a ready set */
|
|
98
|
+
isInteractive: boolean;
|
|
99
|
+
/** Whether an error occurred during processing */
|
|
100
|
+
hasError: boolean;
|
|
101
|
+
/** All processed files */
|
|
102
|
+
files: ProcessedFile[];
|
|
103
|
+
/** Friendly name of what was dropped (ZIP name, folder name, or filename) */
|
|
104
|
+
sourceName: string;
|
|
105
|
+
/** Current status for display */
|
|
106
|
+
status: DropStatus | null;
|
|
107
|
+
/** Whether the dropped files need server-side building before deployment */
|
|
108
|
+
needsBuild: boolean;
|
|
109
|
+
/** Props to spread on the dropzone element (drag & drop, optionally click) */
|
|
110
|
+
getDropzoneProps: (options?: DropzonePropsOptions) => {
|
|
111
|
+
onDragOver: (e: React.DragEvent) => void;
|
|
112
|
+
onDragLeave: (e: React.DragEvent) => void;
|
|
113
|
+
onDrop: (e: React.DragEvent) => void;
|
|
114
|
+
onClick?: () => void;
|
|
115
|
+
};
|
|
116
|
+
/** Props to spread on the hidden file input element */
|
|
117
|
+
getInputProps: () => {
|
|
118
|
+
ref: React.RefObject<HTMLInputElement | null>;
|
|
119
|
+
type: 'file';
|
|
120
|
+
style: {
|
|
121
|
+
display: string;
|
|
122
|
+
};
|
|
123
|
+
multiple: boolean;
|
|
124
|
+
webkitdirectory: string;
|
|
125
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
126
|
+
};
|
|
127
|
+
/** Programmatically trigger the file picker */
|
|
128
|
+
open: () => void;
|
|
129
|
+
/** Process files directly (advanced — loses folder traversal) */
|
|
130
|
+
processFiles: (files: File[]) => Promise<void>;
|
|
131
|
+
/** Reset state and clear all files */
|
|
132
|
+
reset: () => void;
|
|
133
|
+
/** Only the files that passed validation */
|
|
134
|
+
validFiles: ProcessedFile[];
|
|
135
|
+
/** Raw File objects ready for Ship SDK upload */
|
|
136
|
+
getFilesForUpload: () => File[];
|
|
137
|
+
}
|
|
138
|
+
declare function useDrop({ ship }: DropOptions): DropReturn;
|
|
139
|
+
|
|
140
|
+
export { type DropStatus as D, type ProcessedFile as P, type DropOptions as a, type DropPhase as b, type DropReturn as c, type DropzonePropsOptions as d, useDrop as u };
|