@wtfalch/edge 0.14.0 → 0.15.1

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/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type ClientOptions } from './transport.js';
2
- import type { AccessProviderView, AccessReadinessResponse, AdoptAccessAppsRequest, AdoptAccessAppsResponse, AdoptRateLimitRulesRequest, AdoptRateLimitRulesResponse, AdoptRecordsRequest, AdoptRecordsResponse, AdoptTunnelsRequest, AdoptTunnelsResponse, AdoptWorkersRequest, AdoptWorkersResponse, AdoptZonesRequest, AdoptZonesResponse, AvailableResponse, ClaimNameRequest, CreatePlatformAccessAppRequest, CreatePlatformAccessPolicyRequest, CreatePlatformGateRequest, CreatePlatformRateLimitRuleRequest, CreatePlatformRecordRequest, CreatePlatformServiceTokenRequest, CreatePlatformServiceTokenResponse, CreatePlatformTunnelRequest, CreatePlatformWorkerRouteRequest, CreatePlatformZoneRequest, EnsureAccessProviderRequest, EnsureRequestHeaderStripResponse, FindServiceTokenResponse, ListClaimsResponse, ListGatesResponse, ListPlatformAccessAppsRequest, ListPlatformAccessAppsResponse, ListPlatformAccessPoliciesResponse, ListPlatformRateLimitRulesRequest, ListPlatformRateLimitRulesResponse, ListPlatformRecordsRequest, ListPlatformRecordsResponse, ListPlatformTunnelsResponse, ListPlatformWorkerRoutesRequest, ListPlatformWorkerRoutesResponse, ListPlatformWorkerScriptsResponse, ListPlatformZonesRequest, ListPlatformZonesResponse, ListRecordsResponse, MintKeyRequest, MintKeyResponse, NameClaim, OneTimePinProviderView, OrgSettingsRequest, PlatformAccessAppView, PlatformAccessPolicyView, PlatformGateView, PlatformRateLimitRuleView, PlatformRecordView, PlatformTunnelView, PlatformWorkerRouteView, PlatformWorkerScriptView, PlatformZoneView, PutPlatformTunnelConfigRequest, RecordInput, RecordPatch, RecordView, ReplacePlatformGateHostnamesRequest, RequestHeaderStripRule, SetGatePeopleResponse, SetPlatformWorkerSchedulesRequest, SetPlatformWorkerSchedulesResponse, SetPlatformWorkerSecretsRequest, SetPlatformWorkerSecretsResponse, TokenResponse, UpdatePlatformRecordRequest, UploadPlatformWorkerScriptRequest } from './types.js';
2
+ import type { AccessProviderView, AccessReadinessResponse, AdoptAccessAppsRequest, AdoptAccessAppsResponse, AdoptRateLimitRulesRequest, AdoptRateLimitRulesResponse, AdoptRecordsRequest, AdoptRecordsResponse, AdoptTunnelsRequest, AdoptTunnelsResponse, AdoptWorkersRequest, AdoptWorkersResponse, AdoptZonesRequest, AdoptZonesResponse, AvailableResponse, ClaimNameRequest, CreatePlatformAccessAppRequest, CreatePlatformAccessPolicyRequest, CreatePlatformGateRequest, CreatePlatformRateLimitRuleRequest, CreatePlatformRecordRequest, CreatePlatformServiceTokenRequest, CreatePlatformServiceTokenResponse, CreatePlatformTunnelRequest, CreatePlatformWorkerRouteRequest, CreatePlatformZoneRequest, EnsureAccessProviderRequest, EnsurePlatformWorkerContainerRequest, EnsureRequestHeaderStripResponse, FindServiceTokenResponse, ListClaimsResponse, ListGatesResponse, ListPlatformAccessAppsRequest, ListPlatformAccessAppsResponse, ListPlatformAccessPoliciesResponse, ListPlatformRateLimitRulesRequest, ListPlatformRateLimitRulesResponse, ListPlatformRecordsRequest, ListPlatformRecordsResponse, ListPlatformTunnelsResponse, ListPlatformWorkerRoutesRequest, ListPlatformWorkerRoutesResponse, ListPlatformWorkerScriptsResponse, ListPlatformZonesRequest, ListPlatformZonesResponse, ListRecordsResponse, MintKeyRequest, MintKeyResponse, NameClaim, OneTimePinProviderView, OrgSettingsRequest, PlatformAccessAppView, PlatformAccessPolicyView, PlatformGateView, PlatformRateLimitRuleView, PlatformRecordView, PlatformTunnelView, PlatformWorkerContainerView, PlatformWorkerRouteView, PlatformWorkerScriptView, PlatformZoneView, PutPlatformTunnelConfigRequest, RecordInput, RecordPatch, RecordView, RemovePlatformWorkerContainerResponse, RemovePlatformWorkerSecretResponse, ReplacePlatformGateHostnamesRequest, RequestHeaderStripRule, SetGatePeopleResponse, SetPlatformWorkerSchedulesRequest, SetPlatformWorkerSchedulesResponse, SetPlatformWorkerSecretsRequest, SetPlatformWorkerSecretsResponse, TokenResponse, UpdatePlatformRecordRequest, UploadPlatformWorkerScriptRequest } from './types.js';
3
3
  export { EdgeApiError, type ClientOptions } from './transport.js';
4
4
  /** Called with an entry's key: the key carries its organisation and domain, so
5
5
  * no call names either. `setReservedLabels`, `mintKey` and `revokeKey` are
@@ -187,8 +187,14 @@ export declare function createEdgeClient(options: ClientOptions): {
187
187
  * one is skipped (`changed: false`); a new version sets the value again.
188
188
  * Survives `uploadScript` redeploys. Deleting the script removes them. */
189
189
  setSecrets: (name: string, input: SetPlatformWorkerSecretsRequest) => Promise<SetPlatformWorkerSecretsResponse>;
190
+ /** Removes one secret of a script edge owns. Safe to repeat: a secret that is gone counts as removed. Rejects with `not_found` when edge does not own an active script of that name. */
191
+ removeSecret: (name: string, secret: string, organisationId?: string) => Promise<RemovePlatformWorkerSecretResponse>;
192
+ /** Creates the container application of the Durable Object class `className` the script exports (201), or brings the one that exists to the image, instance type and cap (200). Safe to repeat, and a repeat finishes a call that failed halfway. Rejects with `not_found` when the script exports no such class. */
193
+ ensureContainer: (name: string, className: string, input: EnsurePlatformWorkerContainerRequest) => Promise<PlatformWorkerContainerView>;
194
+ /** Deletes a container application of a script edge owns. One that is gone counts as removed; one that serves no edge script rejects with `not_found`. */
195
+ removeContainer: (id: string, organisationId?: string) => Promise<RemovePlatformWorkerContainerResponse>;
190
196
  /** Deletes the script, its cron triggers and the KV namespaces edge created for it; returns the tombstone. Repeating it rejects with `not_found`. */
191
- removeScript: (name: string) => Promise<PlatformWorkerScriptView>;
197
+ removeScript: (name: string, organisationId?: string) => Promise<PlatformWorkerScriptView>;
192
198
  /** Every live route in the zone with its owner (`null`: no row), optionally only one pattern. */
193
199
  listRoutes: (filter: ListPlatformWorkerRoutesRequest) => Promise<ListPlatformWorkerRoutesResponse>;
194
200
  /** Creates the route, or returns the one edge already made for that
@@ -196,7 +202,7 @@ export declare function createEdgeClient(options: ClientOptions): {
196
202
  * holds the pattern that nobody adopted (adopt it instead). */
197
203
  createRoute: (input: CreatePlatformWorkerRouteRequest) => Promise<PlatformWorkerRouteView>;
198
204
  /** Deletes the route in `zoneId`; returns the tombstone. Repeating it rejects with `not_found`. */
199
- removeRoute: (zoneId: string, id: string) => Promise<PlatformWorkerRouteView>;
205
+ removeRoute: (zoneId: string, id: string, organisationId?: string) => Promise<PlatformWorkerRouteView>;
200
206
  /** Writes platform rows for the scripts and routes named. Nothing is
201
207
  * deleted or recreated, and anything nobody names is left alone. */
202
208
  adopt: (input: AdoptWorkersRequest) => Promise<AdoptWorkersResponse>;
package/dist/client.js CHANGED
@@ -195,8 +195,14 @@ export function createEdgeClient(options) {
195
195
  * one is skipped (`changed: false`); a new version sets the value again.
196
196
  * Survives `uploadScript` redeploys. Deleting the script removes them. */
197
197
  setSecrets: (name, input) => call('PUT', `/v1/platform/workers/scripts/${encodeURIComponent(name)}/secrets`, input),
198
+ /** Removes one secret of a script edge owns. Safe to repeat: a secret that is gone counts as removed. Rejects with `not_found` when edge does not own an active script of that name. */
199
+ removeSecret: (name, secret, organisationId) => call('DELETE', `/v1/platform/workers/scripts/${encodeURIComponent(name)}/secrets/${encodeURIComponent(secret)}${organisationId === undefined ? '' : `?${new URLSearchParams({ organisationId })}`}`),
200
+ /** Creates the container application of the Durable Object class `className` the script exports (201), or brings the one that exists to the image, instance type and cap (200). Safe to repeat, and a repeat finishes a call that failed halfway. Rejects with `not_found` when the script exports no such class. */
201
+ ensureContainer: (name, className, input) => call('PUT', `/v1/platform/workers/scripts/${encodeURIComponent(name)}/containers/${encodeURIComponent(className)}`, input),
202
+ /** Deletes a container application of a script edge owns. One that is gone counts as removed; one that serves no edge script rejects with `not_found`. */
203
+ removeContainer: (id, organisationId) => call('DELETE', `/v1/platform/workers/containers/${encodeURIComponent(id)}${organisationId === undefined ? '' : `?${new URLSearchParams({ organisationId })}`}`),
198
204
  /** Deletes the script, its cron triggers and the KV namespaces edge created for it; returns the tombstone. Repeating it rejects with `not_found`. */
199
- removeScript: (name) => call('DELETE', `/v1/platform/workers/scripts/${encodeURIComponent(name)}`),
205
+ removeScript: (name, organisationId) => call('DELETE', `/v1/platform/workers/scripts/${encodeURIComponent(name)}${organisationId === undefined ? '' : `?${new URLSearchParams({ organisationId })}`}`),
200
206
  /** Every live route in the zone with its owner (`null`: no row), optionally only one pattern. */
201
207
  listRoutes: (filter) => call('GET', `/v1/platform/workers/routes?${new URLSearchParams(Object.entries(filter).filter((entry) => entry[1] !== undefined))}`),
202
208
  /** Creates the route, or returns the one edge already made for that
@@ -204,7 +210,7 @@ export function createEdgeClient(options) {
204
210
  * holds the pattern that nobody adopted (adopt it instead). */
205
211
  createRoute: (input) => call('POST', '/v1/platform/workers/routes', input),
206
212
  /** Deletes the route in `zoneId`; returns the tombstone. Repeating it rejects with `not_found`. */
207
- removeRoute: (zoneId, id) => call('DELETE', `/v1/platform/workers/routes/${encodeURIComponent(id)}?${new URLSearchParams({ zoneId })}`),
213
+ removeRoute: (zoneId, id, organisationId) => call('DELETE', `/v1/platform/workers/routes/${encodeURIComponent(id)}?${new URLSearchParams({ zoneId, ...(organisationId === undefined ? {} : { organisationId }) })}`),
208
214
  /** Writes platform rows for the scripts and routes named. Nothing is
209
215
  * deleted or recreated, and anything nobody names is left alone. */
210
216
  adopt: (input) => call('POST', '/v1/platform/workers/adopt', input),
package/dist/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './client.js';
2
+ export * from './worker-app-port.js';
2
3
  export type * from './types.js';
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export * from './client.js';
2
+ export * from './worker-app-port.js';
@@ -5,6 +5,8 @@ export interface ClientOptions {
5
5
  /** The entry's key (foundry's `EDGE_API_KEY`), or the platform credential. */
6
6
  apiKey: string;
7
7
  fetch?: typeof fetch;
8
+ /** Every call is cut off after this long and rejects with `unavailable`. Default 120 s. */
9
+ timeoutMs?: number;
8
10
  }
9
11
  /** A typed failure, never a bare `Error`. `status` is the HTTP status; `code`
10
12
  * and `message` are the service's own wire error (see `EdgeError`). */
package/dist/transport.js CHANGED
@@ -59,14 +59,24 @@ function errorFrom(value) {
59
59
  * every request. No Node-only imports -- this runs in a browser too. */
60
60
  export function createTransport(options) {
61
61
  return async (method, path, input) => {
62
- const response = await (options.fetch ?? fetch)(new URL(path.replace(/^\//, ''), options.url.endsWith('/') ? options.url : `${options.url}/`), {
63
- method,
64
- headers: {
65
- authorization: `Bearer ${options.apiKey}`,
66
- ...(input === undefined ? {} : { 'content-type': 'application/json' }),
67
- },
68
- body: input === undefined ? undefined : JSON.stringify(input),
69
- });
62
+ let response;
63
+ try {
64
+ response = await (options.fetch ?? fetch)(new URL(path.replace(/^\//, ''), options.url.endsWith('/') ? options.url : `${options.url}/`), {
65
+ method,
66
+ headers: {
67
+ authorization: `Bearer ${options.apiKey}`,
68
+ ...(input === undefined ? {} : { 'content-type': 'application/json' }),
69
+ },
70
+ body: input === undefined ? undefined : JSON.stringify(input),
71
+ signal: AbortSignal.timeout(options.timeoutMs ?? 120_000),
72
+ });
73
+ }
74
+ catch (error) {
75
+ // Only the timeout is typed here; any other failure reaches the caller as it was.
76
+ if (error instanceof Error && error.name === 'TimeoutError')
77
+ throw new EdgeApiError(0, 'unavailable', 'Request timed out');
78
+ throw error;
79
+ }
70
80
  let value;
71
81
  try {
72
82
  value = response.status === 204 ? undefined : await response.json();
package/dist/types.d.ts CHANGED
@@ -502,6 +502,8 @@ export interface PlatformWorkerScriptView {
502
502
  /** The company the script serves; `'platform'` when none. `null` for a script with no row. */
503
503
  organisationId: string | null;
504
504
  state: 'pending' | 'active' | 'removing' | 'removed';
505
+ /** The Durable Object migration tag the live script holds, read from Cloudflare by `listScripts`. Absent when it has none. */
506
+ migrationTag?: string;
505
507
  }
506
508
  export interface ListPlatformWorkerScriptsResponse {
507
509
  scripts: PlatformWorkerScriptView[];
@@ -531,8 +533,30 @@ export interface WorkerPlainTextBinding {
531
533
  name: string;
532
534
  text: string;
533
535
  }
536
+ /** A Durable Object namespace for a class the script exports. The class must come from a migration (this upload's or an earlier one). */
537
+ export interface WorkerDurableObjectBinding {
538
+ type: 'durable_object_namespace';
539
+ name: string;
540
+ /** The exported class name. */
541
+ class_name: string;
542
+ }
534
543
  /** Everything `uploadScript` accepts in `bindings`. Secrets are not here: see `setSecrets`. */
535
- export type WorkerBinding = WorkerRateLimitBinding | WorkerKvBinding | WorkerPlainTextBinding;
544
+ export type WorkerBinding = WorkerRateLimitBinding | WorkerKvBinding | WorkerPlainTextBinding | WorkerDurableObjectBinding;
545
+ /** A Durable Object migration applied with the upload. `oldTag` is the tag the
546
+ * live script holds now (omit for a script with none): Cloudflare refuses the
547
+ * upload when it is not. Send only the steps not yet applied. */
548
+ export interface WorkerMigrations {
549
+ oldTag?: string;
550
+ newTag: string;
551
+ steps: Array<{
552
+ newSqliteClasses?: string[];
553
+ deletedClasses?: string[];
554
+ renamedClasses?: Array<{
555
+ from: string;
556
+ to: string;
557
+ }>;
558
+ }>;
559
+ }
536
560
  /** Uploads (or redeploys) a script. Edge fetches nothing: the caller sends the
537
561
  * script, edge checks its size and sha256, and sends it to Cloudflare. The body
538
562
  * is never stored or returned. */
@@ -545,6 +569,10 @@ export interface UploadPlatformWorkerScriptRequest {
545
569
  compatibilityDate: string;
546
570
  /** The script's bindings, all of them: a redeploy replaces the list. Secrets set by `setSecrets` survive a redeploy. */
547
571
  bindings?: WorkerBinding[];
572
+ /** Runtime flags like `nodejs_compat`, all of them: a redeploy replaces the list. */
573
+ compatibilityFlags?: string[];
574
+ /** Durable Object migrations to apply with this upload. */
575
+ migrations?: WorkerMigrations;
548
576
  /** The company the script serves. Omit for a script that serves none. */
549
577
  organisationId?: string;
550
578
  }
@@ -552,6 +580,8 @@ export interface UploadPlatformWorkerScriptRequest {
552
580
  export interface SetPlatformWorkerSchedulesRequest {
553
581
  /** Up to 50 expressions of five fields, like `* * * * *`. Duplicates collapse. */
554
582
  crons: string[];
583
+ /** The company the script serves; edge refuses the call when the script serves another. Omit for a script that serves none. */
584
+ organisationId?: string;
555
585
  }
556
586
  export interface SetPlatformWorkerSchedulesResponse {
557
587
  name: string;
@@ -574,6 +604,8 @@ export interface PlatformWorkerSecretInput {
574
604
  export interface SetPlatformWorkerSecretsRequest {
575
605
  /** 1 to 50 secrets, names unique. */
576
606
  secrets: PlatformWorkerSecretInput[];
607
+ /** The company the script serves; edge refuses the call when the script serves another. Omit for a script that serves none. */
608
+ organisationId?: string;
577
609
  }
578
610
  export interface PlatformWorkerSecretView {
579
611
  name: string;
@@ -585,6 +617,39 @@ export interface SetPlatformWorkerSecretsResponse {
585
617
  name: string;
586
618
  secrets: PlatformWorkerSecretView[];
587
619
  }
620
+ export interface RemovePlatformWorkerSecretResponse {
621
+ name: string;
622
+ secret: string;
623
+ removed: true;
624
+ }
625
+ /** The container application of one Durable Object class on a script edge owns. */
626
+ export interface EnsurePlatformWorkerContainerRequest {
627
+ /** `registry.cloudflare.com/<platform account id>/<repository>@sha256:<digest>`: a digest, never a tag, under the platform account's own path. */
628
+ image: string;
629
+ /** `lite`, `basic`, `standard-1`, `standard-2`, `standard-3` or `standard-4`. */
630
+ instanceType: string;
631
+ /** 1 to 1000. */
632
+ maxInstances: number;
633
+ /** The company the script serves; edge refuses the call when the script serves another. Omit for a script that serves none. */
634
+ organisationId?: string;
635
+ }
636
+ export interface PlatformWorkerContainerView {
637
+ /** Cloudflare's application id: what `removeContainer` takes. */
638
+ id: string;
639
+ script: string;
640
+ className: string;
641
+ image: string;
642
+ instanceType: string;
643
+ maxInstances: number;
644
+ /** True when this call made the application. */
645
+ created: boolean;
646
+ /** True when this call started a rollout to a new image or instance type. */
647
+ rolledOut: boolean;
648
+ }
649
+ export interface RemovePlatformWorkerContainerResponse {
650
+ id: string;
651
+ removed: true;
652
+ }
588
653
  /** A Workers route seen with the platform credential. `id` is Cloudflare's
589
654
  * route id, which every platform route call takes. */
590
655
  export interface PlatformWorkerRouteView {
@@ -0,0 +1,81 @@
1
+ import type { createEdgeClient } from './client.js';
2
+ import type { WorkerBinding, WorkerMigrations } from './types.js';
3
+ /** The edge SDK's platform Workers client: `createEdgeClient({ ... }).platform.workers`. */
4
+ export type EdgePlatformWorkers = ReturnType<typeof createEdgeClient>['platform']['workers'];
5
+ /** What foundry's `WorkerAppPort.uploadWorkerApp` receives. */
6
+ export interface WorkerAppUploadInput {
7
+ name: string;
8
+ artifactUrl: string;
9
+ /** 64 hex characters. */
10
+ artifactSha256: string;
11
+ compatibilityDate: string;
12
+ compatibilityFlags: string[];
13
+ bindings: WorkerBinding[];
14
+ migrations?: WorkerMigrations;
15
+ }
16
+ export interface WorkerAppContainerInput {
17
+ className: string;
18
+ /** `<repository>@sha256:<digest>`. */
19
+ image: string;
20
+ instanceType: string;
21
+ maxInstances: number;
22
+ scriptName: string;
23
+ }
24
+ /**
25
+ * Foundry's `WorkerAppPort` (packages/foundry/src/plan/worker-app.ts), repeated
26
+ * structurally so a host passes {@link createWorkerAppPort}'s result straight in.
27
+ * Every method is idempotent.
28
+ */
29
+ export interface WorkerAppPort {
30
+ scriptExists(name: string): Promise<boolean>;
31
+ findRoute(zoneId: string, pattern: string): Promise<{
32
+ id: string;
33
+ script?: string;
34
+ } | undefined>;
35
+ createRoute(zoneId: string, pattern: string, script?: string): Promise<{
36
+ id: string;
37
+ }>;
38
+ deleteRoute(zoneId: string, routeId: string): Promise<void>;
39
+ deleteScript(name: string): Promise<void>;
40
+ uploadWorkerApp(input: WorkerAppUploadInput): Promise<void>;
41
+ liveMigrationTag(name: string): Promise<string | undefined>;
42
+ setSecrets(name: string, secrets: readonly {
43
+ name: string;
44
+ value: string;
45
+ version: string;
46
+ }[]): Promise<void>;
47
+ deleteSecret(name: string, secretName: string): Promise<void>;
48
+ ensureContainerApplication(input: WorkerAppContainerInput): Promise<{
49
+ id: string;
50
+ }>;
51
+ deleteContainerApplication(id: string): Promise<void>;
52
+ }
53
+ export interface WorkerAppPortOptions {
54
+ /** The company every call acts for. Edge refuses a call on a script, route or container that serves another. */
55
+ organisationId: string;
56
+ /** A read-only GitHub token for the artifact repositories. Sent to api.github.com and nowhere else. */
57
+ githubToken: string;
58
+ fetch?: typeof fetch;
59
+ /** The artifact download is cut off after this long. Default 60 s. */
60
+ artifactTimeoutMs?: number;
61
+ /** Larger artifacts are refused. Default 10 MB, edge's own cap. */
62
+ maxArtifactBytes?: number;
63
+ }
64
+ /** The artifact could not be fetched, or is not the artifact declared. Carries no bytes and no credential. */
65
+ export declare class WorkerAppArtifactError extends Error {
66
+ constructor(message: string);
67
+ }
68
+ /**
69
+ * Foundry's `WorkerAppPort` over edge's `platform.workers`. The adapter
70
+ * fetches the module itself (from a private GitHub release, with `githubToken`) and checks its sha256 before anything is sent: a
71
+ * mismatch throws {@link WorkerAppArtifactError} and edge is never called.
72
+ * Edge checks the digest again. Secret values go to edge and nowhere else; no
73
+ * error made here carries one, or the API key.
74
+ *
75
+ * A script or route that exists with no edge row is not adopted: the upload
76
+ * answers `conflict`, and the caller sees it. `not_found` on a delete counts as
77
+ * gone. Every call is bounded by the client's `timeoutMs`; a failure halfway
78
+ * (the upload done, the container call refused) throws, and repeating the
79
+ * port call finishes the rest because every method is idempotent.
80
+ */
81
+ export declare function createWorkerAppPort(workers: EdgePlatformWorkers, options: WorkerAppPortOptions): WorkerAppPort;
@@ -0,0 +1,285 @@
1
+ import { EdgeApiError } from './transport.js';
2
+ /** The artifact could not be fetched, or is not the artifact declared. Carries no bytes and no credential. */
3
+ export class WorkerAppArtifactError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'WorkerAppArtifactError';
7
+ }
8
+ }
9
+ const SHA256 = /^[0-9a-f]{64}$/i;
10
+ const DEFAULT_MAX_BYTES = 10 * 1024 * 1024;
11
+ const isNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
12
+ const toHex = (buffer) => [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join('');
13
+ function toBase64(bytes) {
14
+ let text = '';
15
+ for (let i = 0; i < bytes.length; i += 0x8000)
16
+ text += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
17
+ return btoa(text);
18
+ }
19
+ const GITHUB_OWNER = 'wtfalch';
20
+ const GITHUB_PATH = /^\/wtfalch\/([A-Za-z0-9._-]+)\/releases\/download\/([^/]+)\/([^/]+)$/;
21
+ /** Where GitHub sends a release asset download. Source: GitHub's list of domains for
22
+ * runners (https://docs.github.com/en/actions/reference/runners/github-hosted-runners).
23
+ * The asset endpoint answers 302 or streams (https://docs.github.com/en/rest/releases/assets). */
24
+ const ASSET_HOSTS = new Set([
25
+ 'release-assets.githubusercontent.com',
26
+ 'objects.githubusercontent.com',
27
+ 'objects-origin.githubusercontent.com',
28
+ 'github-releases.githubusercontent.com',
29
+ ]);
30
+ const MAX_REDIRECTS = 3;
31
+ /** `https://github.com/wtfalch/<repo>/releases/download/<tag>/<file>` and nothing else. */
32
+ function parseArtifactUrl(url) {
33
+ let parsed;
34
+ try {
35
+ parsed = new URL(url);
36
+ }
37
+ catch {
38
+ throw new WorkerAppArtifactError('artifactUrl is not a URL');
39
+ }
40
+ if (parsed.protocol !== 'https:')
41
+ throw new WorkerAppArtifactError('artifactUrl must be https');
42
+ if (parsed.hostname !== 'github.com' ||
43
+ parsed.port !== '' ||
44
+ parsed.username !== '' ||
45
+ parsed.password !== '' ||
46
+ parsed.search !== '' ||
47
+ parsed.hash !== '')
48
+ throw new WorkerAppArtifactError(`artifactUrl must be https://github.com/${GITHUB_OWNER}/<repo>/releases/download/<tag>/<file>`);
49
+ const match = GITHUB_PATH.exec(parsed.pathname);
50
+ if (!match)
51
+ throw new WorkerAppArtifactError(`artifactUrl must be https://github.com/${GITHUB_OWNER}/<repo>/releases/download/<tag>/<file>`);
52
+ let tag;
53
+ let file;
54
+ try {
55
+ tag = decodeURIComponent(match[2]);
56
+ file = decodeURIComponent(match[3]);
57
+ }
58
+ catch {
59
+ throw new WorkerAppArtifactError('artifactUrl is not a GitHub release asset URL');
60
+ }
61
+ if ([tag, file].some((part) => part === '.' || part === '..' || part.includes('/')))
62
+ throw new WorkerAppArtifactError('artifactUrl is not a GitHub release asset URL');
63
+ return { repo: match[1], tag, file };
64
+ }
65
+ /** A redirect target is an https GitHub asset host by name: no port, no userinfo, no address literal. */
66
+ function checkAssetLocation(location, from) {
67
+ let target;
68
+ try {
69
+ target = new URL(location, from);
70
+ }
71
+ catch {
72
+ throw new WorkerAppArtifactError('artifact redirect is not a URL');
73
+ }
74
+ if (target.protocol !== 'https:' ||
75
+ target.port !== '' ||
76
+ target.username !== '' ||
77
+ target.password !== '' ||
78
+ !ASSET_HOSTS.has(target.hostname))
79
+ throw new WorkerAppArtifactError('artifact redirect to a host that is not GitHub asset storage');
80
+ return target;
81
+ }
82
+ /** Reads the body, aborting at `max` bytes without buffering the rest. */
83
+ async function readCapped(response, max) {
84
+ const declared = Number(response.headers.get('content-length'));
85
+ if (Number.isFinite(declared) && declared > max) {
86
+ await response.body?.cancel();
87
+ throw new WorkerAppArtifactError(`artifact must be 1 byte to ${max} bytes`);
88
+ }
89
+ if (!response.body)
90
+ throw new WorkerAppArtifactError('artifact fetch failed');
91
+ const reader = response.body.getReader();
92
+ const chunks = [];
93
+ let total = 0;
94
+ for (;;) {
95
+ const { done, value } = await reader.read();
96
+ if (done)
97
+ break;
98
+ total += value.length;
99
+ if (total > max) {
100
+ await reader.cancel();
101
+ throw new WorkerAppArtifactError(`artifact must be 1 byte to ${max} bytes`);
102
+ }
103
+ chunks.push(value);
104
+ }
105
+ const bytes = new Uint8Array(total);
106
+ let offset = 0;
107
+ for (const chunk of chunks) {
108
+ bytes.set(chunk, offset);
109
+ offset += chunk.length;
110
+ }
111
+ return bytes;
112
+ }
113
+ /**
114
+ * Fetches the release asset through GitHub's API and refuses it unless its
115
+ * sha256 is the declared one. The token goes to api.github.com only; the
116
+ * download redirect is followed by hand, to GitHub's asset hosts only, and
117
+ * without the token.
118
+ */
119
+ async function fetchVerified(url, sha256, options) {
120
+ if (!SHA256.test(sha256))
121
+ throw new WorkerAppArtifactError('artifactSha256 must be 64 hex digits');
122
+ const { repo, tag, file } = parseArtifactUrl(url);
123
+ const max = options.maxArtifactBytes ?? DEFAULT_MAX_BYTES;
124
+ const doFetch = options.fetch ?? fetch;
125
+ const signal = AbortSignal.timeout(options.artifactTimeoutMs ?? 60_000);
126
+ const api = (path, accept) => doFetch(`https://api.github.com/repos/${GITHUB_OWNER}/${repo}/${path}`, {
127
+ redirect: 'manual',
128
+ signal,
129
+ headers: {
130
+ Accept: accept,
131
+ Authorization: `Bearer ${options.githubToken}`,
132
+ 'X-GitHub-Api-Version': '2022-11-28',
133
+ 'User-Agent': 'edge-worker-app-port',
134
+ },
135
+ });
136
+ let bytes;
137
+ try {
138
+ const release = await api(`releases/tags/${encodeURIComponent(tag)}`, 'application/vnd.github+json');
139
+ if (!release.ok)
140
+ throw new WorkerAppArtifactError(`artifact release lookup answered status ${release.status}`);
141
+ const assets = (await release.json())
142
+ .assets;
143
+ const asset = assets?.find((a) => a.name === file);
144
+ if (!asset || typeof asset.id !== 'number' || !Number.isSafeInteger(asset.id))
145
+ throw new WorkerAppArtifactError('the release has no asset of that name');
146
+ let response = await api(`releases/assets/${asset.id}`, 'application/octet-stream');
147
+ let from = new URL(`https://api.github.com/repos/${GITHUB_OWNER}/${repo}/releases/assets/${asset.id}`);
148
+ for (let hops = 0; response.status >= 300 && response.status < 400; hops++) {
149
+ const location = response.headers.get('location');
150
+ await response.body?.cancel();
151
+ if (!location || hops >= MAX_REDIRECTS)
152
+ throw new WorkerAppArtifactError('artifact fetch failed');
153
+ from = checkAssetLocation(location, from);
154
+ // No Authorization header: the asset host never sees the token.
155
+ response = await doFetch(from, { redirect: 'manual', signal });
156
+ }
157
+ if (!response.ok)
158
+ throw new WorkerAppArtifactError(`artifact fetch answered status ${response.status}`);
159
+ bytes = await readCapped(response, max);
160
+ }
161
+ catch (error) {
162
+ if (error instanceof WorkerAppArtifactError)
163
+ throw error;
164
+ // The cause is dropped: it can carry a signed download URL.
165
+ throw new WorkerAppArtifactError(error instanceof Error && error.name === 'TimeoutError'
166
+ ? 'artifact fetch timed out'
167
+ : 'artifact fetch failed');
168
+ }
169
+ if (bytes.length === 0 || bytes.length > max)
170
+ throw new WorkerAppArtifactError(`artifact must be 1 byte to ${max} bytes`);
171
+ const actual = toHex(await crypto.subtle.digest('SHA-256', bytes));
172
+ if (actual !== sha256.toLowerCase())
173
+ throw new WorkerAppArtifactError(`artifact sha256 ${actual} is not the declared ${sha256.toLowerCase()}`);
174
+ return bytes;
175
+ }
176
+ /**
177
+ * Foundry's `WorkerAppPort` over edge's `platform.workers`. The adapter
178
+ * fetches the module itself (from a private GitHub release, with `githubToken`) and checks its sha256 before anything is sent: a
179
+ * mismatch throws {@link WorkerAppArtifactError} and edge is never called.
180
+ * Edge checks the digest again. Secret values go to edge and nowhere else; no
181
+ * error made here carries one, or the API key.
182
+ *
183
+ * A script or route that exists with no edge row is not adopted: the upload
184
+ * answers `conflict`, and the caller sees it. `not_found` on a delete counts as
185
+ * gone. Every call is bounded by the client's `timeoutMs`; a failure halfway
186
+ * (the upload done, the container call refused) throws, and repeating the
187
+ * port call finishes the rest because every method is idempotent.
188
+ */
189
+ export function createWorkerAppPort(workers, options) {
190
+ const org = options.organisationId;
191
+ // A script that serves another organisation is not visible: edge would refuse the upload anyway.
192
+ const liveScript = async (name) => {
193
+ const { scripts } = await workers.listScripts({ name });
194
+ return scripts.find((s) => s.name === name &&
195
+ s.state !== 'removed' &&
196
+ (s.organisationId === null || s.organisationId === org));
197
+ };
198
+ return {
199
+ async scriptExists(name) {
200
+ return (await liveScript(name)) !== undefined;
201
+ },
202
+ async findRoute(zoneId, pattern) {
203
+ const { routes } = await workers.listRoutes({ zoneId, pattern });
204
+ const route = routes.find((r) => r.pattern === pattern &&
205
+ r.state !== 'removed' &&
206
+ (r.organisationId === null || r.organisationId === org));
207
+ return route
208
+ ? { id: route.id, ...(route.script ? { script: route.script } : {}) }
209
+ : undefined;
210
+ },
211
+ async createRoute(zoneId, pattern, script) {
212
+ const route = await workers.createRoute({
213
+ zoneId,
214
+ pattern,
215
+ organisationId: org,
216
+ ...(script ? { script } : {}),
217
+ });
218
+ return { id: route.id };
219
+ },
220
+ async deleteRoute(zoneId, routeId) {
221
+ try {
222
+ await workers.removeRoute(zoneId, routeId, org);
223
+ }
224
+ catch (error) {
225
+ if (!isNotFound(error))
226
+ throw error;
227
+ }
228
+ },
229
+ async deleteScript(name) {
230
+ try {
231
+ await workers.removeScript(name, org);
232
+ }
233
+ catch (error) {
234
+ if (!isNotFound(error))
235
+ throw error;
236
+ }
237
+ },
238
+ async uploadWorkerApp(input) {
239
+ const bytes = await fetchVerified(input.artifactUrl, input.artifactSha256, options);
240
+ await workers.uploadScript(input.name, {
241
+ script: toBase64(bytes),
242
+ organisationId: org,
243
+ artifactSha256: input.artifactSha256.toLowerCase(),
244
+ compatibilityDate: input.compatibilityDate,
245
+ ...(input.compatibilityFlags.length
246
+ ? { compatibilityFlags: input.compatibilityFlags }
247
+ : {}),
248
+ bindings: input.bindings,
249
+ ...(input.migrations ? { migrations: input.migrations } : {}),
250
+ });
251
+ },
252
+ async liveMigrationTag(name) {
253
+ return (await liveScript(name))?.migrationTag;
254
+ },
255
+ async setSecrets(name, secrets) {
256
+ if (secrets.length === 0)
257
+ return;
258
+ await workers.setSecrets(name, {
259
+ organisationId: org,
260
+ secrets: secrets.map((s) => ({ ...s })),
261
+ });
262
+ },
263
+ async deleteSecret(name, secretName) {
264
+ try {
265
+ await workers.removeSecret(name, secretName, org);
266
+ }
267
+ catch (error) {
268
+ if (!isNotFound(error))
269
+ throw error;
270
+ }
271
+ },
272
+ async ensureContainerApplication(input) {
273
+ const { id } = await workers.ensureContainer(input.scriptName, input.className, {
274
+ organisationId: org,
275
+ image: input.image,
276
+ instanceType: input.instanceType,
277
+ maxInstances: input.maxInstances,
278
+ });
279
+ return { id };
280
+ },
281
+ async deleteContainerApplication(id) {
282
+ await workers.removeContainer(id, org);
283
+ },
284
+ };
285
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/edge",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Public client SDK and API types for the edge service.",
@@ -19,7 +19,9 @@
19
19
  "dist/transport.d.ts",
20
20
  "dist/transport.js",
21
21
  "dist/types.d.ts",
22
- "dist/types.js"
22
+ "dist/types.js",
23
+ "dist/worker-app-port.d.ts",
24
+ "dist/worker-app-port.js"
23
25
  ],
24
26
  "exports": {
25
27
  ".": {