@amerilux/netsuite-api 0.1.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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/dist/client/apiClient.d.ts +57 -0
  4. package/dist/client/apiClient.js +96 -0
  5. package/dist/client/index.d.ts +8 -0
  6. package/dist/client/index.js +6 -0
  7. package/dist/index.d.ts +76 -0
  8. package/dist/index.js +9 -0
  9. package/dist/server/apiError.d.ts +9 -0
  10. package/dist/server/apiError.js +18 -0
  11. package/dist/server/defineRestlet.d.ts +15 -0
  12. package/dist/server/defineRestlet.js +4 -0
  13. package/dist/server/defineSuitelet.d.ts +16 -0
  14. package/dist/server/defineSuitelet.js +20 -0
  15. package/dist/server/endpoint.d.ts +59 -0
  16. package/dist/server/endpoint.js +95 -0
  17. package/dist/server/fileCabinet.d.ts +14 -0
  18. package/dist/server/fileCabinet.js +40 -0
  19. package/dist/server/index.d.ts +21 -0
  20. package/dist/server/index.js +14 -0
  21. package/dist/server/rawResponse.d.ts +39 -0
  22. package/dist/server/rawResponse.js +28 -0
  23. package/dist/server/suiteletClient.d.ts +23 -0
  24. package/dist/server/suiteletClient.js +54 -0
  25. package/dist/testing/N/error.d.ts +2 -0
  26. package/dist/testing/N/error.js +6 -0
  27. package/dist/testing/N/file.d.ts +9 -0
  28. package/dist/testing/N/file.js +6 -0
  29. package/dist/testing/N/format.d.ts +8 -0
  30. package/dist/testing/N/format.js +4 -0
  31. package/dist/testing/N/https.d.ts +15 -0
  32. package/dist/testing/N/https.js +10 -0
  33. package/dist/testing/N/log.d.ts +5 -0
  34. package/dist/testing/N/log.js +5 -0
  35. package/dist/testing/N/query.d.ts +7 -0
  36. package/dist/testing/N/query.js +7 -0
  37. package/dist/testing/N/record.d.ts +14 -0
  38. package/dist/testing/N/record.js +11 -0
  39. package/dist/testing/N/runtime.d.ts +11 -0
  40. package/dist/testing/N/runtime.js +8 -0
  41. package/dist/testing/N/search.d.ts +10 -0
  42. package/dist/testing/N/search.js +8 -0
  43. package/dist/testing/N/task.d.ts +7 -0
  44. package/dist/testing/N/task.js +4 -0
  45. package/dist/testing/N/ui/serverWidget.d.ts +7 -0
  46. package/dist/testing/N/ui/serverWidget.js +7 -0
  47. package/dist/testing/N/url.d.ts +9 -0
  48. package/dist/testing/N/url.js +6 -0
  49. package/dist/testing/index.d.ts +28 -0
  50. package/dist/testing/index.js +32 -0
  51. package/dist-tooling/appReader.d.ts +12 -0
  52. package/dist-tooling/appReader.js +31 -0
  53. package/dist-tooling/cli/arguments.d.ts +9 -0
  54. package/dist-tooling/cli/arguments.js +34 -0
  55. package/dist-tooling/cli/bin.d.ts +2 -0
  56. package/dist-tooling/cli/bin.js +7 -0
  57. package/dist-tooling/cli/main.d.ts +13 -0
  58. package/dist-tooling/cli/main.js +88 -0
  59. package/dist-tooling/config.d.ts +43 -0
  60. package/dist-tooling/config.js +77 -0
  61. package/dist-tooling/controllerReader.d.ts +68 -0
  62. package/dist-tooling/controllerReader.js +267 -0
  63. package/dist-tooling/emit.d.ts +29 -0
  64. package/dist-tooling/emit.js +79 -0
  65. package/dist-tooling/file-system.d.ts +15 -0
  66. package/dist-tooling/file-system.js +53 -0
  67. package/dist-tooling/generate.d.ts +36 -0
  68. package/dist-tooling/generate.js +128 -0
  69. package/dist-tooling/index.d.ts +15 -0
  70. package/dist-tooling/index.js +8 -0
  71. package/dist-tooling/scriptsReader.d.ts +17 -0
  72. package/dist-tooling/scriptsReader.js +57 -0
  73. package/package.json +77 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AmeriLux
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # @amerilux/netsuite-api
2
+
3
+ The API layer for a NetSuite single-page app. The app's server side is SuiteScript; its browser side is a bundle served by a Suitelet. This package holds what sits between them, so a project writes controllers and services and nothing else:
4
+
5
+ - **`@amerilux/netsuite-api/server`**: declare a controller's endpoints and the script that serves them, expose them as a Restlet or a Suitelet, reject a call with an `ApiError`, call another Suitelet controller from server code, and find a File Cabinet file by name.
6
+ - **`@amerilux/netsuite-api/client`**: a typed browser client per controller, built from the endpoint types.
7
+ - **`@amerilux/netsuite-api/testing`**: stubs for the `N/*` modules and the vitest wiring that routes imports to them.
8
+ - **`netsuite-api generate`**: reads the controllers and writes the client's whole view of the backend, one client module and one copy of the entity types, plus the server-side map of scripts. The client never imports from the server tree.
9
+ - **`@amerilux/netsuite-api`** (the root): the wire itself. The envelope, the endpoint types, `ScriptDeclaration`, `ScriptRef`.
10
+
11
+ The layout it assumes is the one `create-netsuite-project` scaffolds: `api/` (SuiteScript) and `client/` (React) as workspaces, and `netsuite.ts` at the root holding the application's names.
12
+
13
+ ## A controller
14
+
15
+ One file per script under `api/src/controllers/`, named `<name>Controller.ts`. The file declares the wire shapes, the endpoints, and the script that serves them:
16
+
17
+ ```ts
18
+ /**
19
+ * @NApiVersion 2.1
20
+ * @NScriptType Restlet
21
+ * @NModuleScope SameAccount
22
+ */
23
+ import { defineEndpoints, defineRestlet } from '@amerilux/netsuite-api/server';
24
+ import type { Customer } from '../types/models.gen';
25
+ import { findCustomer, searchCustomers } from '../services/customerService';
26
+
27
+ export interface CustomerSearchRequest {
28
+ search: string;
29
+ }
30
+
31
+ export type CustomerSummary = Pick<Customer, 'id' | 'companyName'>;
32
+
33
+ export const customerEndpoints = defineEndpoints({
34
+ /** Customers whose name contains the search text. */
35
+ search: (request: CustomerSearchRequest): CustomerSummary[] => searchCustomers(request.search),
36
+ byId: (request: { id: number }): Customer => findCustomer(request.id),
37
+ });
38
+
39
+ export type CustomerEndpoints = typeof customerEndpoints;
40
+
41
+ export const post = defineRestlet({
42
+ name: 'customer',
43
+ scriptId: 'customscript_app_customer',
44
+ deployId: 'customdeploy_app_customer',
45
+ }, customerEndpoints);
46
+ ```
47
+
48
+ Every call is a POST whose JSON body carries the request plus an `endpoint` property naming the endpoint. The handler answers with data, or throws an `ApiError` for a status the caller should see; anything else is a 500 with the details logged. A Suitelet controller is the same file with `defineSuitelet` and `onRequest` instead, and `browser: false` in the declaration when only server code calls it.
49
+
50
+ The declaration is the controller's own statement of the script it is deployed as. It creates nothing: the controller builds, tests and bundles before any script record exists. The ids are how a client reaches the controller once it is deployed, so set them to whatever the record and deployment are called in NetSuite and in the SDF object; the generator wires every client to them.
51
+
52
+ The generator reads the file as source, so a few things are rules rather than conventions. Each of them is an error with a message when broken:
53
+
54
+ - Handlers are written inline with their parameter and return types annotated. A reference to a service function carries no types the generator can read.
55
+ - Every type in the file is a wire shape and is exported.
56
+ - A type is imported only from the carried modules (the generated entity types, by default) or from another controller. A service's return type is never used as a DTO by reference.
57
+ - The script declaration is an object literal with literal ids, its `name` is the file name without `Controller`, and the entry point export and the `@NScriptType` header agree with the define function.
58
+ - Type names and script ids are unique across controllers.
59
+
60
+ ## What the generator writes
61
+
62
+ `netsuite-api generate`, run from the project root, writes four things:
63
+
64
+ **`client/src/api/index.gen.ts`**, the client's view of the backend. For every controller its wire shapes and endpoint type, and for every browser-facing controller a client built from the declared script:
65
+
66
+ ```ts
67
+ import { createApiClient } from '@amerilux/netsuite-api/client';
68
+ import type { Customer } from './models.gen';
69
+
70
+ // customer (api/src/controllers/customerController.ts)
71
+
72
+ export interface CustomerSearchRequest {
73
+ search: string;
74
+ }
75
+
76
+ export type CustomerSummary = Pick<Customer, 'id' | 'companyName'>;
77
+
78
+ export type CustomerEndpoints = {
79
+ /** Customers whose name contains the search text. */
80
+ search: (request: CustomerSearchRequest) => CustomerSummary[];
81
+ byId: (request: { id: number }) => Customer;
82
+ };
83
+
84
+ export const customerApi = createApiClient<CustomerEndpoints>({ kind: 'restlet', scriptId: 'customscript_app_customer', deployId: 'customdeploy_app_customer' });
85
+ ```
86
+
87
+ A hook calls `customerApi.search({ search: 'acme' })` and gets a `Promise<CustomerSummary[]>`. The second argument carries an `AbortSignal`.
88
+
89
+ **`client/src/api/models.gen.ts`**, a copy of the api's generated entity types, so the carried imports resolve.
90
+
91
+ **`client/src/app.gen.ts`**, a verbatim copy of the app file, outside the api folder so a page or a component may import `app` without touching a client.
92
+
93
+ **`api/src/scripts.gen.ts`**, the server-side map of every declared script by controller name. A repository passes an entry to `createSuiteletClient`; nothing else needs it.
94
+
95
+ `netsuite-api check` exits non-zero when any generated file is missing or out of date, for CI. `netsuite-api generate --dry-run` prints the client module instead of writing anything.
96
+
97
+ ### The app file
98
+
99
+ `netsuite.ts` at the project root holds the application's names and any id no controller or model owns. Both sides use it, so the client gets a verbatim copy: the file is exported constants and types only, with no imports and nothing that runs.
100
+
101
+ ### Configuration
102
+
103
+ `netsuite-api.config.json` at the project root, every setting optional. The defaults:
104
+
105
+ ```json
106
+ {
107
+ "controllers": "api/src/controllers",
108
+ "appFile": "netsuite.ts",
109
+ "outFile": "client/src/api/index.gen.ts",
110
+ "appOutFile": "client/src/app.gen.ts",
111
+ "scriptsOutFile": "api/src/scripts.gen.ts",
112
+ "clientModule": "@amerilux/netsuite-api/client",
113
+ "wireModule": "@amerilux/netsuite-api",
114
+ "typeImports": { "../types/models.gen": "./models.gen", "@amerilux/netsuite-api/server": "@amerilux/netsuite-api/client" },
115
+ "copyFiles": { "api/src/types/models.gen.ts": "client/src/api/models.gen.ts" }
116
+ }
117
+ ```
118
+
119
+ Paths are relative to the config file. `typeImports` maps a specifier as written in a controller to the specifier the client resolves; only listed specifiers may be imported for types (the package's server entry maps to its client entry so `RawResponse` carries over). `copyFiles` copies the files those specifiers point at.
120
+
121
+ ## The client at runtime
122
+
123
+ Calls go to NetSuite's own Restlet and Suitelet paths on the current origin, riding the session. A development server that proxies to a sandbox sets its own paths once at startup:
124
+
125
+ ```ts
126
+ import { configureApiClient } from '@amerilux/netsuite-api/client';
127
+
128
+ if (import.meta.env.DEV) configureApiClient({ basePaths: { restlet: '/api/restlet', suitelet: '/api/suitelet' } });
129
+ ```
130
+
131
+ A failed call rejects with an `ApiClientError` carrying the envelope's status and message.
132
+
133
+ ## Authorizing calls
134
+
135
+ A Restlet runs as the caller's role, and NetSuite's own permissions apply to every record the handler touches. When a controller needs a rule of its own, such as an endpoint only some roles may call, the define call takes an `authorize` hook, run before every handler once the endpoint is known to exist:
136
+
137
+ ```ts
138
+ import * as runtime from 'N/runtime';
139
+ import { ApiError, defineEndpoints, defineRestlet } from '@amerilux/netsuite-api/server';
140
+
141
+ const ADMINISTRATOR = 3;
142
+
143
+ export const post = defineRestlet({ name: 'orders', scriptId: '...', deployId: '...' }, ordersEndpoints, {
144
+ authorize: ({ endpoint }) => {
145
+ if (endpoint === 'remove' && Number(runtime.getCurrentUser().role) !== ADMINISTRATOR) throw ApiError.forbidden('Only an administrator removes orders.');
146
+ },
147
+ });
148
+ ```
149
+
150
+ The hook sees the controller, the endpoint name and the request. Throwing an `ApiError` answers with its status and message; returning lets the call through. Reading the session belongs in a repository function in a project that keeps to its layers, so a real hook calls one.
151
+
152
+ ## Answering with a document
153
+
154
+ Every endpoint answers with the JSON envelope, except a Suitelet endpoint that returns `rawResponse(...)`: a CSV export, a rendered PDF, a File Cabinet file. The handler writes `RawResponse` as its return type, exactly, and the generator lists the endpoint on the client, which resolves it to a `Blob`:
155
+
156
+ ```ts
157
+ import type { RawResponse } from '@amerilux/netsuite-api/server';
158
+ import { defineEndpoints, defineSuitelet, rawResponse } from '@amerilux/netsuite-api/server';
159
+
160
+ export const documentsEndpoints = defineEndpoints({
161
+ csv: (request: { month: string }): RawResponse => rawResponse({ contentType: 'text/csv', body: buildCsv(request.month), fileName: `orders-${request.month}.csv` }),
162
+ invoicePdf: (request: { id: number }): RawResponse => rawResponse({ file: renderInvoice(request.id), inline: true }),
163
+ });
164
+ ```
165
+
166
+ In the browser, `documentsApi.csv({ month })` resolves to a `Blob`; hand it to `URL.createObjectURL` for a download link. A text answer takes a content type, a body, optional headers and, for a download, a file name; a file answer takes an `N/file` object and whether to show it inline. A failure still arrives as the envelope and is thrown as an `ApiClientError`. A Restlet cannot write a raw answer: a handler returning one there is a 500.
167
+
168
+ ## Calling a Suitelet from server code
169
+
170
+ A Restlet runs as the caller's role. When a lookup needs a role the caller lacks, put it behind a Suitelet deployed to run as that role, declare it `browser: false`, and call it from a repository through the generated scripts map:
171
+
172
+ ```ts
173
+ import { createSuiteletClient } from '@amerilux/netsuite-api/server';
174
+ import type { UserRolesEndpoints } from '../controllers/userRolesController';
175
+ import { scripts } from '../scripts.gen';
176
+
177
+ const userRolesApi = createSuiteletClient<UserRolesEndpoints>(scripts.userRoles);
178
+ export const listRolesForEmployee = (employeeId: number) => userRolesApi.byEmployee({ employeeId }).roles;
179
+ ```
180
+
181
+ ## Tests
182
+
183
+ `N/*` modules exist only inside NetSuite. The `testing` entry ships a stub per module, each export a `vi.fn()`, and the vitest wiring:
184
+
185
+ ```ts
186
+ import { inlinedPackagesForNetsuiteStubs, netsuiteModuleStubAliases } from '@amerilux/netsuite-api/testing';
187
+
188
+ export default defineConfig({
189
+ resolve: { alias: [...netsuiteModuleStubAliases()] },
190
+ test: { server: { deps: { inline: [...inlinedPackagesForNetsuiteStubs] } } },
191
+ });
192
+ ```
193
+
194
+ Inlining matters: vitest leaves node_modules to Node's resolver by default, and Node knows no `N/log`. With the package inlined, its own `N/*` imports go through the alias too.
195
+
196
+ ## Development
197
+
198
+ ```
199
+ npm install
200
+ npm run typecheck
201
+ npm test
202
+ npm run build
203
+ ```
204
+
205
+ Tag `v<version>` matching `package.json` to publish.
206
+
207
+ ## License
208
+
209
+ MIT
@@ -0,0 +1,57 @@
1
+ import { type EndpointRequest, type EndpointResponse, type Endpoints, type RawResponse, type ScriptKind, type ScriptRef } from '../index.js';
2
+ /**
3
+ * Calls an API controller by its script and deployment ids, one endpoint at a time. The entry's
4
+ * `kind` decides the URL, so a controller can move between Restlet and Suitelet without touching
5
+ * the caller. In the deployed app the call rides the NetSuite session on the same origin; a
6
+ * development server that proxies to a sandbox sets its own base paths with configureApiClient.
7
+ */
8
+ export interface ApiCallOptions {
9
+ signal?: AbortSignal;
10
+ }
11
+ export declare class ApiClientError extends Error {
12
+ readonly status: number;
13
+ readonly details?: unknown | undefined;
14
+ constructor(status: number, message: string, details?: unknown | undefined);
15
+ }
16
+ /** The path each script kind is served from, without the script and deploy query parameters. */
17
+ export type ApiBasePaths = Record<ScriptKind, string>;
18
+ /** Where NetSuite serves Restlets and Suitelets on the account's own origin. */
19
+ export declare const NETSUITE_API_BASE_PATHS: ApiBasePaths;
20
+ export interface ApiClientConfiguration {
21
+ /** Base paths to use instead of NetSuite's own, e.g. the routes of a local development proxy. */
22
+ basePaths?: Partial<ApiBasePaths>;
23
+ }
24
+ /**
25
+ * Sets where calls go. Call it once at startup, before the first call; a client built earlier picks
26
+ * the change up because the URL is built per call. Without a call, NetSuite's own paths apply.
27
+ */
28
+ export declare function configureApiClient(configuration: ApiClientConfiguration): void;
29
+ export declare function buildApiUrl(scriptRef: ScriptRef): string;
30
+ /** Calls one endpoint of a controller: a POST whose JSON body carries the request and the endpoint name. */
31
+ export declare function callEndpoint<TData>(scriptRef: ScriptRef, endpointName: string, request?: object, options?: ApiCallOptions): Promise<TData>;
32
+ /**
33
+ * Calls an endpoint that answers with a document instead of the envelope (a Suitelet handler returning
34
+ * rawResponse) and resolves to its body as a Blob. A failure still arrives as an envelope, and is
35
+ * thrown as an ApiClientError with its status.
36
+ */
37
+ export declare function callRawEndpoint(scriptRef: ScriptRef, endpointName: string, request?: object, options?: ApiCallOptions): Promise<Blob>;
38
+ /** What the client resolves to for an endpoint's response type: a Blob for a raw answer, the data otherwise. */
39
+ export type ClientResponse<TResponse> = TResponse extends RawResponse ? Blob : TResponse;
40
+ /**
41
+ * One function per endpoint, typed by the controller's handlers: `userApi.roles()`,
42
+ * `ordersApi.byId({ id })`, `exportsApi.csv({ month })` resolving to a Blob. The request comes first,
43
+ * the call options second.
44
+ */
45
+ export type ApiClient<TEndpoints extends Endpoints> = {
46
+ readonly [TName in keyof TEndpoints]: (request: EndpointRequest<TEndpoints[TName]>, options?: ApiCallOptions) => Promise<ClientResponse<EndpointResponse<TEndpoints[TName]>>>;
47
+ };
48
+ export interface ApiClientOptions {
49
+ /** The endpoints that answer with a document: the generator lists every handler whose return type is RawResponse. */
50
+ rawEndpoints?: readonly string[];
51
+ }
52
+ /**
53
+ * Builds the typed client for a controller from its script: `createApiClient<UserEndpoints>({ kind, scriptId, deployId })`.
54
+ * The endpoint names come from the type alone; the property accessed is the endpoint named on the wire.
55
+ * The generated client module of a project calls this once per browser-facing controller.
56
+ */
57
+ export declare function createApiClient<TEndpoints extends Endpoints>(scriptRef: ScriptRef, clientOptions?: ApiClientOptions): ApiClient<TEndpoints>;
@@ -0,0 +1,96 @@
1
+ import { ENDPOINT_PARAMETER } from '../index.js';
2
+ export class ApiClientError extends Error {
3
+ constructor(status, message, details) {
4
+ super(message);
5
+ this.status = status;
6
+ this.details = details;
7
+ this.name = 'ApiClientError';
8
+ }
9
+ }
10
+ /** Where NetSuite serves Restlets and Suitelets on the account's own origin. */
11
+ export const NETSUITE_API_BASE_PATHS = {
12
+ restlet: '/app/site/hosting/restlet.nl',
13
+ suitelet: '/app/site/hosting/scriptlet.nl',
14
+ };
15
+ let configuredBasePaths = NETSUITE_API_BASE_PATHS;
16
+ /**
17
+ * Sets where calls go. Call it once at startup, before the first call; a client built earlier picks
18
+ * the change up because the URL is built per call. Without a call, NetSuite's own paths apply.
19
+ */
20
+ export function configureApiClient(configuration) {
21
+ configuredBasePaths = { ...NETSUITE_API_BASE_PATHS, ...configuration.basePaths };
22
+ }
23
+ export function buildApiUrl(scriptRef) {
24
+ const parameters = new URLSearchParams({ script: scriptRef.scriptId, deploy: scriptRef.deployId });
25
+ return `${configuredBasePaths[scriptRef.kind]}?${parameters.toString()}`;
26
+ }
27
+ /** The POST every call is: the request plus the endpoint name, as JSON. */
28
+ function postEndpoint(scriptRef, endpointName, request, options) {
29
+ return fetch(buildApiUrl(scriptRef), {
30
+ method: 'POST',
31
+ credentials: 'same-origin',
32
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
33
+ body: JSON.stringify({ ...request, [ENDPOINT_PARAMETER]: endpointName }),
34
+ signal: options.signal,
35
+ });
36
+ }
37
+ function parseEnvelope(text) {
38
+ try {
39
+ const parsed = text ? JSON.parse(text) : undefined;
40
+ return parsed && typeof parsed === 'object' && 'status' in parsed ? parsed : undefined;
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ /** Calls one endpoint of a controller: a POST whose JSON body carries the request and the endpoint name. */
47
+ export async function callEndpoint(scriptRef, endpointName, request = {}, options = {}) {
48
+ var _a;
49
+ const response = await postEndpoint(scriptRef, endpointName, request, options);
50
+ const text = await response.text();
51
+ const envelope = parseEnvelope(text);
52
+ if (!envelope) {
53
+ throw new ApiClientError(response.status, `Unexpected response from ${scriptRef.scriptId} (${response.status})`, text.slice(0, 500));
54
+ }
55
+ if (envelope.error !== null || envelope.status >= 400) {
56
+ throw new ApiClientError(envelope.status, (_a = envelope.error) !== null && _a !== void 0 ? _a : `Request failed (${envelope.status})`);
57
+ }
58
+ return envelope.data;
59
+ }
60
+ /**
61
+ * Calls an endpoint that answers with a document instead of the envelope (a Suitelet handler returning
62
+ * rawResponse) and resolves to its body as a Blob. A failure still arrives as an envelope, and is
63
+ * thrown as an ApiClientError with its status.
64
+ */
65
+ export async function callRawEndpoint(scriptRef, endpointName, request = {}, options = {}) {
66
+ var _a, _b;
67
+ const response = await postEndpoint(scriptRef, endpointName, request, options);
68
+ const contentType = (_a = response.headers.get('Content-Type')) !== null && _a !== void 0 ? _a : '';
69
+ if (!response.ok || contentType.includes('application/json')) {
70
+ const text = await response.text();
71
+ const envelope = parseEnvelope(text);
72
+ if (envelope && (envelope.error !== null || envelope.status >= 400))
73
+ throw new ApiClientError(envelope.status, (_b = envelope.error) !== null && _b !== void 0 ? _b : `Request failed (${envelope.status})`);
74
+ if (!response.ok)
75
+ throw new ApiClientError(response.status, `Unexpected response from ${scriptRef.scriptId} (${response.status})`, text.slice(0, 500));
76
+ throw new ApiClientError(response.status, `${scriptRef.scriptId}.${endpointName} answered JSON where a document was expected.`, text.slice(0, 500));
77
+ }
78
+ return response.blob();
79
+ }
80
+ /**
81
+ * Builds the typed client for a controller from its script: `createApiClient<UserEndpoints>({ kind, scriptId, deployId })`.
82
+ * The endpoint names come from the type alone; the property accessed is the endpoint named on the wire.
83
+ * The generated client module of a project calls this once per browser-facing controller.
84
+ */
85
+ export function createApiClient(scriptRef, clientOptions = {}) {
86
+ var _a;
87
+ const rawEndpoints = new Set((_a = clientOptions.rawEndpoints) !== null && _a !== void 0 ? _a : []);
88
+ return new Proxy({}, {
89
+ get(_target, endpointName) {
90
+ if (typeof endpointName !== 'string')
91
+ return undefined;
92
+ const call = rawEndpoints.has(endpointName) ? callRawEndpoint : callEndpoint;
93
+ return (request, options) => call(scriptRef, endpointName, (request !== null && request !== void 0 ? request : {}), options);
94
+ },
95
+ });
96
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The browser side of the API: a typed client per controller, built from the endpoint types the
3
+ * generator writes into the project's client module. Runs in the browser only; nothing here imports N/*.
4
+ */
5
+ export { ApiClientError, NETSUITE_API_BASE_PATHS, buildApiUrl, callEndpoint, callRawEndpoint, configureApiClient, createApiClient } from './apiClient.js';
6
+ export type { ApiBasePaths, ApiCallOptions, ApiClient, ApiClientConfiguration, ApiClientOptions, ClientResponse } from './apiClient.js';
7
+ export { ENDPOINT_PARAMETER } from '../index.js';
8
+ export type { ApiEnvelope, ApiErrorBody, Endpoint, Endpoints, EndpointRequest, EndpointResponse, RawResponse, ScriptDeclaration, ScriptKind, ScriptRef } from '../index.js';
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The browser side of the API: a typed client per controller, built from the endpoint types the
3
+ * generator writes into the project's client module. Runs in the browser only; nothing here imports N/*.
4
+ */
5
+ export { ApiClientError, NETSUITE_API_BASE_PATHS, buildApiUrl, callEndpoint, callRawEndpoint, configureApiClient, createApiClient } from './apiClient.js';
6
+ export { ENDPOINT_PARAMETER } from '../index.js';
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The wire: what a controller answers with, how a call names its endpoint, and how a script is
3
+ * reached. Both sides import this module; nothing in it touches NetSuite or the browser.
4
+ */
5
+ /** Every controller answers with this envelope; `data` is null whenever `error` is set. */
6
+ export interface ApiEnvelope<TData> {
7
+ status: number;
8
+ error: string | null;
9
+ data: TData | null;
10
+ }
11
+ /** The body of a failed call, as thrown by the client's ApiClientError. */
12
+ export interface ApiErrorBody {
13
+ status: number;
14
+ error: string;
15
+ details?: unknown;
16
+ }
17
+ /**
18
+ * Every call is a POST whose JSON body carries the request plus this property, naming which endpoint
19
+ * of the controller the call is for.
20
+ */
21
+ export declare const ENDPOINT_PARAMETER = "endpoint";
22
+ /**
23
+ * An endpoint as the controller declares it: a synchronous function from a request to a response.
24
+ * The parameter type is the request shape and the return type the response shape; a handler with no
25
+ * parameter takes no request. The clients derive their call signatures from these types.
26
+ */
27
+ export type Endpoint = (request: never) => unknown;
28
+ /** A controller's endpoints by name: `typeof userEndpoints`, the type the clients are built from. */
29
+ export type Endpoints = Record<string, Endpoint>;
30
+ /** The request type of an endpoint, or void when its handler takes no parameter. */
31
+ export type EndpointRequest<TEndpoint extends Endpoint> = Parameters<TEndpoint> extends [] ? void : Parameters<TEndpoint>[0];
32
+ /** The response type of an endpoint: what its handler returns. */
33
+ export type EndpointResponse<TEndpoint extends Endpoint> = ReturnType<TEndpoint>;
34
+ /**
35
+ * What a Suitelet endpoint returns to answer with something other than the JSON envelope: a file
36
+ * download, a CSV, a rendered PDF. The server builds one with rawResponse(); the browser client
37
+ * resolves such an endpoint to a Blob. A Restlet cannot answer with one. A handler that answers this
38
+ * way writes `RawResponse` as its return type, exactly, so the generator can tell the client.
39
+ */
40
+ export interface RawResponse {
41
+ readonly isRawResponse: true;
42
+ }
43
+ /** How a script is reached over HTTP; the client builds the URL from it. */
44
+ export type ScriptKind = 'restlet' | 'suitelet';
45
+ /**
46
+ * What a controller declares about the script that serves it, passed to defineRestlet or
47
+ * defineSuitelet. Nothing here creates or deploys anything: the controller builds, tests and bundles
48
+ * before a script record exists. The ids are how a client reaches the controller once it is deployed,
49
+ * so they are set to whatever the script record and its deployment are called in NetSuite (and in
50
+ * the SDF object under netsuite/Objects); the generator wires every client to them as long as they
51
+ * match.
52
+ */
53
+ export interface ScriptDeclaration {
54
+ /** The controller's name, as the logs and the generated clients call it: `user` for userController.ts. */
55
+ name: string;
56
+ /**
57
+ * The script record's id, `customscript_<prefix>_<name>`. Change it freely to match the record in
58
+ * NetSuite; the generated clients follow.
59
+ */
60
+ scriptId: string;
61
+ /** The deployment's id, `customdeploy_<prefix>_<name>`, with the same freedom as scriptId. */
62
+ deployId: string;
63
+ /**
64
+ * False when only server code calls the script (a Suitelet deployed to run as another role, called
65
+ * through the Suitelet client). The generator then emits the controller's types but no browser
66
+ * client. Defaults to true.
67
+ */
68
+ browser?: boolean;
69
+ }
70
+ /** One deployed script as a client reaches it: the declaration plus how it is served. */
71
+ export interface ScriptRef {
72
+ kind: ScriptKind;
73
+ scriptId: string;
74
+ deployId: string;
75
+ browser?: boolean;
76
+ }
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The wire: what a controller answers with, how a call names its endpoint, and how a script is
3
+ * reached. Both sides import this module; nothing in it touches NetSuite or the browser.
4
+ */
5
+ /**
6
+ * Every call is a POST whose JSON body carries the request plus this property, naming which endpoint
7
+ * of the controller the call is for.
8
+ */
9
+ export const ENDPOINT_PARAMETER = 'endpoint';
@@ -0,0 +1,9 @@
1
+ /** Thrown by domain code or handlers; the controller wrapper turns it into the envelope's status and error. */
2
+ export declare class ApiError extends Error {
3
+ readonly status: number;
4
+ readonly details?: unknown | undefined;
5
+ constructor(status: number, message: string, details?: unknown | undefined);
6
+ static badRequest(message: string, details?: unknown): ApiError;
7
+ static notFound(message: string, details?: unknown): ApiError;
8
+ static forbidden(message?: string): ApiError;
9
+ }
@@ -0,0 +1,18 @@
1
+ /** Thrown by domain code or handlers; the controller wrapper turns it into the envelope's status and error. */
2
+ export class ApiError extends Error {
3
+ constructor(status, message, details) {
4
+ super(message);
5
+ this.status = status;
6
+ this.details = details;
7
+ this.name = 'ApiError';
8
+ }
9
+ static badRequest(message, details) {
10
+ return new ApiError(400, message, details);
11
+ }
12
+ static notFound(message, details) {
13
+ return new ApiError(404, message, details);
14
+ }
15
+ static forbidden(message = 'Not permitted') {
16
+ return new ApiError(403, message);
17
+ }
18
+ }
@@ -0,0 +1,15 @@
1
+ import type { ApiEnvelope, Endpoints, ScriptDeclaration } from '../index.js';
2
+ import { type ControllerOptions } from './endpoint.js';
3
+ /**
4
+ * Exposes a controller's endpoints as a Restlet:
5
+ *
6
+ * export const post = defineRestlet({ name: 'user', scriptId: 'customscript_app_user', deployId: 'customdeploy_app_user' }, userEndpoints);
7
+ *
8
+ * Every call is a POST whose JSON body names the endpoint, so `post` is the only entry point a
9
+ * controller exports. The declaration names the script the controller is deployed as; the generator
10
+ * reads it from this call to build the clients. The options carry the authorize hook, run before
11
+ * every handler. A Restlet always answers with the JSON envelope; an endpoint that returns a raw
12
+ * response belongs in a Suitelet.
13
+ */
14
+ export type RestletEntryPoint = (requestBody: unknown) => ApiEnvelope<unknown>;
15
+ export declare function defineRestlet(script: ScriptDeclaration, endpoints: Endpoints, options?: ControllerOptions): RestletEntryPoint;
@@ -0,0 +1,4 @@
1
+ import { invokeEndpoint } from './endpoint.js';
2
+ export function defineRestlet(script, endpoints, options = {}) {
3
+ return (requestBody) => invokeEndpoint(script.name, endpoints, requestBody, { ...options, allowRawResponse: false }).envelope;
4
+ }
@@ -0,0 +1,16 @@
1
+ import type { EntryPoints } from 'N/types';
2
+ import type { Endpoints, ScriptDeclaration } from '../index.js';
3
+ import { type ControllerOptions } from './endpoint.js';
4
+ /**
5
+ * Exposes a controller's endpoints as a JSON Suitelet:
6
+ *
7
+ * export const onRequest = defineSuitelet({ name: 'userRoles', scriptId: '...', deployId: '...', browser: false }, userRolesEndpoints);
8
+ *
9
+ * A POST's JSON body names the endpoint; any other method is answered 405. The response is the same
10
+ * envelope a Restlet returns, so a caller does not care which transport answered, except for an
11
+ * endpoint that returns rawResponse(...): a Suitelet writes that as it is, headers and body or file.
12
+ * The declaration names the script the controller is deployed as; the generator reads it from this
13
+ * call. The options carry the authorize hook, run before every handler.
14
+ */
15
+ export type SuiteletEntryPoint = (context: EntryPoints.Suitelet.onRequestContext) => void;
16
+ export declare function defineSuitelet(script: ScriptDeclaration, endpoints: Endpoints, options?: ControllerOptions): SuiteletEntryPoint;
@@ -0,0 +1,20 @@
1
+ import { invokeEndpoint } from './endpoint.js';
2
+ import { writeRawResponse } from './rawResponse.js';
3
+ function writeEnvelope(context, envelope) {
4
+ context.response.setHeader({ name: 'Content-Type', value: 'application/json' });
5
+ context.response.write({ output: JSON.stringify(envelope) });
6
+ }
7
+ export function defineSuitelet(script, endpoints, options = {}) {
8
+ return (context) => {
9
+ const method = context.request.method.toUpperCase();
10
+ if (method !== 'POST') {
11
+ writeEnvelope(context, { status: 405, error: `${script.name} answers POST, not ${method}.`, data: null });
12
+ return;
13
+ }
14
+ const outcome = invokeEndpoint(script.name, endpoints, context.request.body, { ...options, allowRawResponse: true });
15
+ if (outcome.raw)
16
+ writeRawResponse(context.response, outcome.raw);
17
+ else
18
+ writeEnvelope(context, outcome.envelope);
19
+ };
20
+ }
@@ -0,0 +1,59 @@
1
+ import { type ApiEnvelope, type Endpoints } from '../index.js';
2
+ import { type RawResponseOptions } from './rawResponse.js';
3
+ /**
4
+ * Endpoints are the transport-agnostic unit of an API controller: named, each a synchronous function
5
+ * from a request to a response, declared together in the controller file with the request and
6
+ * response shapes they speak. The handler signatures are the contract: a client is built from
7
+ * `typeof <name>Endpoints` and calls each endpoint by name. Every call is a POST whose body names the
8
+ * endpoint. The controller file wraps the map as a Restlet (`defineRestlet`) or a Suitelet
9
+ * (`defineSuitelet`); switching transport is a change to that one statement and its SDF object, never
10
+ * to the endpoints.
11
+ */
12
+ /**
13
+ * Declares a controller's endpoints. Annotate each handler's parameter with its request type and its
14
+ * return value with its response type; both reach the clients through the type, and the client
15
+ * generator reads them from the annotations.
16
+ */
17
+ export declare function defineEndpoints<TEndpoints extends Endpoints>(endpoints: TEndpoints): TEndpoints;
18
+ /** NetSuite hands a body as either an object or a JSON string. */
19
+ export declare function parseEndpointRequest(rawRequest: unknown): unknown;
20
+ export interface EndpointCall {
21
+ /** The endpoint named by the request, or undefined when the property is missing. */
22
+ name: string | undefined;
23
+ /** Everything else in the body: the endpoint's own input. */
24
+ request: Record<string, unknown>;
25
+ }
26
+ /** Splits the endpoint name off the parsed body. */
27
+ export declare function readEndpointCall(parsedRequest: unknown): EndpointCall;
28
+ /** The call as the authorize hook sees it, once the endpoint is known to exist. */
29
+ export interface EndpointCallContext {
30
+ controller: string;
31
+ endpoint: string;
32
+ request: Record<string, unknown>;
33
+ }
34
+ /**
35
+ * Runs before every handler of a controller. Throw an ApiError to reject the call with its status
36
+ * (`ApiError.forbidden()` for a role the session lacks); return to let it through. The hook decides
37
+ * from the endpoint name and the request, and from whatever N/runtime says about the caller.
38
+ */
39
+ export type AuthorizeEndpoint = (call: EndpointCallContext) => void;
40
+ /** What a controller may add to its define call, after the endpoints. */
41
+ export interface ControllerOptions {
42
+ authorize?: AuthorizeEndpoint;
43
+ }
44
+ export interface InvokeEndpointOptions extends ControllerOptions {
45
+ /** True for a Suitelet, which can write a raw response; a Restlet cannot, so a raw answer is a 500 there. */
46
+ allowRawResponse?: boolean;
47
+ }
48
+ /** The envelope to send, or the raw answer to write instead. */
49
+ export interface EndpointOutcome {
50
+ envelope: ApiEnvelope<unknown>;
51
+ raw?: RawResponseOptions;
52
+ }
53
+ /**
54
+ * Runs the endpoint the body names and produces the outcome: 200 with data (or a raw answer), an
55
+ * ApiError's own status and message (400 without an endpoint name, 404 for an unknown one, whatever
56
+ * the authorize hook threw), or 500 with the details logged. Audits the outcome and timing either
57
+ * way. Log titles are constant phrases; the controller, endpoint and ids live in the details object.
58
+ */
59
+ export declare function invokeEndpoint(controllerName: string, endpoints: Endpoints, rawRequest: unknown, options?: InvokeEndpointOptions): EndpointOutcome;