diff --git a/docs/native-workers.md b/docs/native-workers.md index f3a06fc..24a1692 100644 --- a/docs/native-workers.md +++ b/docs/native-workers.md @@ -17,7 +17,7 @@ Native Workers deploy ordinary Cloudflare Worker modules with their default hand An app belongs to a private or team library and a workspace. Its UUID permanently owns provider resources; source revisions, export names, logical library moves and binding names do not change those identities. -Give a native Worker a root URL like any other artifact: use **Link** under its **Settings** in the gallery, or `artifact_link({kind:"app",name,slug,access})`. Slugs share the deployment-wide namespace with artifacts. Private links admit the app's current library, following it across library moves; public links admit any caller, and the app can implement its own authentication. The app receives paths relative to the slug, so `/my-app/jobs` arrives as `/jobs`. `app_read` and `app_list` report the link's `slug`, `access` and `url`. +Give a native Worker a root URL like any other artifact: use **Sharing** under its **Settings** in the gallery, or `artifact_link({kind:"app",name,slug,access})`. Slugs share the deployment-wide namespace with artifacts. Private links admit the app's current library, following it across library moves; public links admit any caller, and the app can implement its own authentication. The app receives paths relative to the slug, so `/my-app/jobs` arrives as `/jobs`. `app_read` and `app_list` report the link's `slug`, `access` and `url`. Every app also keeps its management URL, which is always private: @@ -26,7 +26,7 @@ Every app also keeps its management URL, which is always private: /team/research/my-app/ ``` -Open and **Settings → Link** use the saved slug when one exists. Otherwise they use the app's authenticated `management_url`, also returned by `app_read`, `app_write`, and `app_list`. A team link works for signed-in teammates. A personal link works only for its owner, even if the viewer has an app with the same name. Personal usernames default to the lowercase part of the sign-in email before the @: for example, `nico@example.com` gets `nico`, then another Nico gets `nico2` if that name is free. If the sign-in provides no usable email prefix, the base is `user`. The assigned name stays with the account if the email changes. You can choose a different permanent username in a personal app's **Settings → Link**; previously copied links still work. +Open and **Settings → Sharing** use the saved slug when one exists. Otherwise they use the app's authenticated `management_url`, also returned by `app_read`, `app_write`, and `app_list`. A team link works for signed-in teammates. A personal link works only for its owner, even if the viewer has an app with the same name. Personal usernames default to the lowercase part of the sign-in email before the @: for example, `nico@example.com` gets `nico`, then another Nico gets `nico2` if that name is free. If the sign-in provides no usable email prefix, the base is `user`. The assigned name stays with the account if the email changes. You can choose a different permanent username in a personal app's **Settings → Sharing**; previously copied links still work. Relative links keep the same workspace and library, and query parameters belong to the app. Older `/apps//?workspace=…&library=…` URLs still work. The `users` and `team` root slugs can no longer be claimed. On deployments that already use either slug, the root link remains, but paths matching the new app URL shapes now route to authenticated apps. diff --git a/e2e/gallery-native-apps.test.ts b/e2e/gallery-native-apps.test.ts index f39eeb4..1a1caee 100644 --- a/e2e/gallery-native-apps.test.ts +++ b/e2e/gallery-native-apps.test.ts @@ -135,6 +135,7 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi let failFirst = true; let failRestore = true; let failReconcile = true; + let secretNames: string[] = []; const server = Bun.serve({ port: 0, hostname: "127.0.0.1", @@ -217,7 +218,8 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi saved!.status = "active"; return result({ ...saved, ok: true }); case "app_secrets": - return result({ names: ["TOKEN"] }); + if (call.arguments.secrets) secretNames = ["TOKEN", "UNDECLARED"]; + return result({ names: secretNames }); case "app_move": saved = null; return result({ ok: true }); @@ -329,17 +331,30 @@ test("Worker gallery deploys drafts, manages revisions/secrets and preserves edi expect(new URL(page.url()).searchParams.get("app")).toBe("example"); expect(new URL(page.url()).searchParams.get("appTab")).toBe("deployments"); await panel.getByRole("tab", { name: "Settings", exact: true }).click(); - await panel.getByLabel("Secret name", { exact: true }).fill("TOKEN"); await panel.getByLabel("Personal username").fill("nico"); await panel.getByRole("button", { name: "Save username" }).click(); await panel.getByLabel("Personal username").waitFor({ state: "hidden" }); expect(await panel.getByLabel("Share link").inputValue()).toBe(new URL("/users/nico/test/example/", server.url).href); expect(await panel.getByRole("link", { name: "Open app" }).getAttribute("href")).toBe("/users/nico/test/example/"); + const settings = panel.getByRole("tabpanel", { name: "Settings", exact: true }); + const secretRows = settings.getByRole("list", { name: "Secret names", exact: true }).getByRole("listitem"); + await secretRows.filter({ hasText: "TOKEN" }).getByText("Needs a value", { exact: true }).waitFor(); + await secretRows.filter({ hasText: "TOKEN" }).getByRole("button", { name: "Set value", exact: true }).click(); + expect(await panel.getByLabel("Secret name", { exact: true }).inputValue()).toBe("TOKEN"); + expect(await panel.getByLabel("Secret value", { exact: true }).evaluate((input) => input === document.activeElement)).toBe(true); + // Sections that save on their own sit inside the panel, below the deploy action that does not cover them. + const deployBox = await settings.getByRole("button", { name: "Save and deploy Worker", exact: true }).boundingBox(); + for (const heading of ["Secrets", "Sharing", "Library"]) { + const headingBox = await settings.getByRole("heading", { name: heading, exact: true }).boundingBox(); + expect(headingBox!.y).toBeGreaterThan(deployBox!.y); + } await panel.getByLabel("Secret value", { exact: true }).fill("secret-fixture-value"); const writesBeforeSecret = calls.filter((call) => call.name === "app_write").length; await panel.getByRole("button", { name: "Save secret", exact: true }).click(); await panel.getByText("Secret saved", { exact: true }).waitFor(); expect(await panel.getByLabel("Secret value", { exact: true }).inputValue()).toBe(""); + expect(await settings.getByText("Needs a value", { exact: true }).count()).toBe(0); + await secretRows.filter({ hasText: "UNDECLARED" }).getByText("Not passed to the Worker", { exact: true }).waitFor(); expect(calls.filter((call) => call.name === "app_write").length).toBe(writesBeforeSecret); expect(await panel.locator(".cm-content").innerText()).toContain("local edit"); await panel.getByRole("button", { name: "Save and deploy Worker", exact: true }).click(); diff --git a/src/gallery/native-apps.tsx b/src/gallery/native-apps.tsx index 8c27cb2..f2304c8 100644 --- a/src/gallery/native-apps.tsx +++ b/src/gallery/native-apps.tsx @@ -1,4 +1,4 @@ -import { useEffect, useImperativeHandle, useRef, useState, type Ref } from "react"; +import { useEffect, useImperativeHandle, useRef, useState, type FormEvent, type ReactNode, type Ref } from "react"; import { GalleryToolError, LinkSettings, galleryTool } from "./hosted"; import { ProjectEditor, editableProject, emptyEditableProject, type EditableProject } from "./project-editor"; import { SecretControls } from "./secrets"; @@ -183,6 +183,34 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS }; const providerAvailable = providers.includes(provider); const detail = (!!selected || editingNew) && providerAvailable; + function deploy(event: FormEvent) { + event.preventDefault(); + void perform(async () => { + const result = decode( + await galleryTool(workspace, "app_write", { + name, + source, + manifest: JSON.parse(manifest), + project, + provider, + expected_revision: baseRevision, + }), + ); + setDirty(false); + await onSaved(); + await loadApp(name); + navigate(name); + setStatus(result.ok ? "Native Worker deployed" : "Native Worker needs reconciliation"); + }, true); + } + const deployControls = ( + <> + + {dirty ? Unsaved changes : null} + + ); return ( } @@ -203,7 +231,7 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS .worker-workspace [role="tabpanel"][hidden] { display: none; } .worker-workspace .worker-source-panel { min-height: 300px; padding: 0; } .worker-workspace .worker-source-panel > .project-editor { height: 100%; } - .worker-workspace .worker-content > :is(p, .worker-empty, .script-fields, h3, button) { margin: 12px 16px; } + .worker-workspace .worker-content > :is(p, .worker-empty, .script-fields) { margin: 12px 16px; } .worker-workspace .source-actions { flex-wrap: wrap; } .worker-workspace input, .worker-workspace textarea { max-width: 100%; box-sizing: border-box; } .worker-workspace fieldset { min-width: 0; } @@ -216,13 +244,38 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS .worker-workspace [role="tabpanel"]:not(.worker-source-panel) { max-width: 760px; } .worker-workspace [role="tabpanel"] > h2 { margin: 0 0 6px; font-size: 14px; font-weight: 600; } .worker-workspace [role="tabpanel"] > h3 { margin: 24px 0 6px; font-size: 13px; font-weight: 600; } - .worker-workspace .worker-content > h3 { font-size: 13px; font-weight: 600; } .worker-workspace legend { padding: 0; margin-bottom: 8px; font-size: 12px; font-weight: 600; } + .worker-workspace [role="tabpanel"].worker-settings { padding-block: 0; } + .settings-section { padding-block: 24px; } + .settings-section + .settings-section { border-top: 1px solid var(--line); } + .settings-section > h2 { margin: 0 0 4px; font-size: 14px; font-weight: 600; } + .worker-settings .settings-description { margin-bottom: 16px; } + .worker-settings .secret-controls { max-width: none; padding: 0; } + .worker-settings .settings-feedback { margin-top: 8px; } + .worker-settings .settings-feedback:empty { min-height: 0; margin: 0; } + .worker-settings .link-settings { padding: 0; margin-top: 12px; } + .worker-settings .link-settings > label:has(input) { flex: 1; } + .worker-settings .link-settings > label input { width: 100%; } + .worker-settings :is(.settings-stack, .settings-section) > .settings-actions { margin-top: 0; } + .settings-stack { display: flex; flex-direction: column; gap: 20px; } + .settings-list { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; min-width: 0; margin: 0; padding: 0; border: 0; } + .worker-workspace .settings-list legend { margin-bottom: 4px; } + .settings-hint { margin: 0; color: var(--muted); font-size: 12px; } + .settings-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-self: stretch; gap: 8px; } + .settings-row.variable-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; } + .settings-row input { width: 100%; font-family: var(--font-mono); } + .settings-row :is(input, button), .settings-list > button { height: 36px; } .worker-manifest { display: block; width: 100%; min-height: 240px; margin-top: 6px; padding: 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--text); font: 12px/1.6 var(--font-mono); } @media (max-width: 1100px) { .worker-workspace .detail-header { flex-wrap: wrap; gap: 8px; } .worker-workspace .view-control { order: 3; width: 100%; } } + @media (max-width: 760px) { + .settings-row.variable-row { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } + .settings-row.variable-row button { grid-column: 1 / -1; justify-self: end; } + .settings-row :is(input, button), .settings-list > button { height: 44px; } + .settings-row input { font-size: 16px; } + } `}
@@ -339,29 +392,8 @@ export function NativeAppEditor({ workspace, appName, providers, onNavigate, onS {detail ? ( <> {selected?.error ?

{selected.error}

: null} -
{ - event.preventDefault(); - void perform(async () => { - const result = decode( - await galleryTool(workspace, "app_write", { - name, - source, - manifest: JSON.parse(manifest), - project, - provider, - expected_revision: baseRevision, - }), - ); - setDirty(false); - await onSaved(); - await loadApp(name); - navigate(name); - setStatus(result.ok ? "Native Worker deployed" : "Native Worker needs reconciliation"); - }, true); - }} - > + {/* Settings holds its own forms, so it cannot render inside this one. */} + - -

Settings

- {parsed ? ( - <> -
- - -
- updateManifest({ compatibility_flags })} - /> - updateManifest({ vars })} - /> - updateManifest({ secrets })} - /> - - ) : ( -

Enter a valid manifest object below to use structured settings.

- )} -
- Advanced manifest (JSON) -