eraspace 0.1.0 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # eraspace
2
2
 
3
- Build and deploy realtime apps written in TypeScript. The server runs on the BEAM with Postgres storage, live queries
3
+ Build and deploy realtime apps written in TypeScript. The server runs in V8 with Postgres storage, live queries
4
4
  and WebSockets; the client is Preact. Apps need no `package.json`, `tsconfig.json` or `node_modules`: the CLI bundles
5
5
  the SDK, Preact and TypeScript.
6
6
 
@@ -23,16 +23,34 @@ sign in and claim it (`eraspace auth login`, then `eraspace claim`).
23
23
  | --- | --- |
24
24
  | `new <name>` / `init` | Create an app in a new directory or the current one |
25
25
  | `typecheck` | Typecheck `server/`, `client/` and `shared/` |
26
- | `deploy` | Build the client, compile the server and deploy |
27
- | `dev` | Deploy, then redeploy on every save |
28
- | `call <fn> '<json>'...` | Call a query, mutation or action |
29
- | `logs` | Read handler logs (`--follow` to stream) |
26
+ | `deploy` | Build the client, compile the server and deploy (`--delete-table <table>=<rows>` confirms deleting a removed table) |
27
+ | `dev` | Deploy, then redeploy whenever `server/`, `client/`, `shared/` or `.env.eraspace.server` change |
28
+ | `call <fn> '<json>'...` | Call a query, mutation or action, each argument a JSON value |
29
+ | `logs [app]` | Read handler logs (`--follow` to stream) |
30
30
  | `inspect` | Show the schema, functions and deploys |
31
- | `auth login` / `claim` | Sign in, and take ownership of an unclaimed app |
31
+ | `auth login` / `logout` / `whoami` | Sign in with your browser, sign out, or show who you are |
32
+ | `claim` | Take ownership of this checkout's unclaimed app |
33
+ | `apps` | List your apps |
34
+ | `names` / `names add` / `names rm` | List, add or remove the app's names |
35
+ | `inspection public\|private [app]` | Open or close inspection and logs to everyone |
36
+ | `archive` / `restore [app]` | Stop serving an app and keep its data, or serve it again |
37
+ | `terminate [app] --yes` | Stop an app permanently: its data is kept but it accepts no more deploys |
38
+ | `token create` / `list` / `revoke` | Manage API tokens for one app, or for all your apps with `--personal` |
32
39
 
33
- `eraspace --help` and `eraspace <command> --help` list every flag.
40
+ Commands act on the app bound to the current checkout. Pass `--app <name or id>` or set `ERASPACE_APP` to choose
41
+ another. `eraspace --help` and `eraspace <command> --help` list every flag.
42
+
43
+ ## CI
44
+
45
+ Create a token with `eraspace token create` (add `--read` for inspection and logs only), then set `ERASPACE_TOKEN` and
46
+ `ERASPACE_TOKEN_API=<platform origin>` in CI. The CLI never sends the token to any other origin.
34
47
 
35
48
  ## Platform
36
49
 
37
50
  The CLI deploys to the hosted eraspace platform by default. Set `ERASPACE_URL` or pass `--url` to use another one, such
38
51
  as a local platform at `http://localhost:4000`.
52
+
53
+ ## Upgrading from 0.1
54
+
55
+ 0.2 targets the V8 platform and its JSON socket protocol. Apps deployed with 0.1 are not carried over: deploy them
56
+ afresh with 0.2, which also rebuilds their browser bundles against the new SDK.
package/bin.js CHANGED
@@ -15424,12 +15424,12 @@ var posixImpl = /* @__PURE__ */ make21({
15424
15424
  resolve,
15425
15425
  normalize(path) {
15426
15426
  if (path.length === 0) return ".";
15427
- const isAbsolute = path.charCodeAt(0) === 47;
15427
+ const isAbsolute2 = path.charCodeAt(0) === 47;
15428
15428
  const trailingSeparator = path.charCodeAt(path.length - 1) === 47;
15429
- path = normalizeStringPosix(path, !isAbsolute);
15430
- if (path.length === 0 && !isAbsolute) path = ".";
15429
+ path = normalizeStringPosix(path, !isAbsolute2);
15430
+ if (path.length === 0 && !isAbsolute2) path = ".";
15431
15431
  if (path.length > 0 && trailingSeparator) path += "/";
15432
- if (isAbsolute) return "/" + path;
15432
+ if (isAbsolute2) return "/" + path;
15433
15433
  return path;
15434
15434
  },
15435
15435
  isAbsolute(path) {
@@ -15649,9 +15649,9 @@ var posixImpl = /* @__PURE__ */ make21({
15649
15649
  };
15650
15650
  if (path.length === 0) return ret;
15651
15651
  let code = path.charCodeAt(0);
15652
- const isAbsolute = code === 47;
15652
+ const isAbsolute2 = code === 47;
15653
15653
  let start;
15654
- if (isAbsolute) {
15654
+ if (isAbsolute2) {
15655
15655
  ret.root = "/";
15656
15656
  start = 1;
15657
15657
  } else {
@@ -15687,11 +15687,11 @@ var posixImpl = /* @__PURE__ */ make21({
15687
15687
  preDotState === 0 || // The (right-most) trimmed path component is exactly '..'
15688
15688
  preDotState === 1 && startDot === end3 - 1 && startDot === startPart + 1) {
15689
15689
  if (end3 !== -1) {
15690
- if (startPart === 0 && isAbsolute) ret.base = ret.name = path.slice(1, end3);
15690
+ if (startPart === 0 && isAbsolute2) ret.base = ret.name = path.slice(1, end3);
15691
15691
  else ret.base = ret.name = path.slice(startPart, end3);
15692
15692
  }
15693
15693
  } else {
15694
- if (startPart === 0 && isAbsolute) {
15694
+ if (startPart === 0 && isAbsolute2) {
15695
15695
  ret.name = path.slice(1, startDot);
15696
15696
  ret.base = path.slice(1, end3);
15697
15697
  } else {
@@ -15701,7 +15701,7 @@ var posixImpl = /* @__PURE__ */ make21({
15701
15701
  ret.ext = path.slice(startDot, end3);
15702
15702
  }
15703
15703
  if (startPart > 0) ret.dir = path.slice(0, startPart - 1);
15704
- else if (isAbsolute) ret.dir = "/";
15704
+ else if (isAbsolute2) ret.dir = "/";
15705
15705
  return ret;
15706
15706
  },
15707
15707
  sep: "/",
@@ -38939,7 +38939,7 @@ var runWith2 = (command2, config2) => {
38939
38939
  // cli/package.json
38940
38940
  var package_default = {
38941
38941
  name: "eraspace-cli",
38942
- version: "0.1.0",
38942
+ version: "0.2.0",
38943
38943
  private: true,
38944
38944
  license: "MIT",
38945
38945
  type: "module",
@@ -39468,7 +39468,7 @@ var whoami = Effect_exports.fnUntraced(function* (config2) {
39468
39468
  import { basename } from "node:path";
39469
39469
 
39470
39470
  // cli/src/build.ts
39471
- import { dirname as dirname2, join as join5, relative as relative2, resolve as resolve8, sep } from "node:path";
39471
+ import { dirname as dirname2, isAbsolute, join as join5, relative as relative2, resolve as resolve8, sep } from "node:path";
39472
39472
  import * as esbuild from "esbuild";
39473
39473
  import { parseSync } from "oxc-parser";
39474
39474
  var toPosix = (p) => p.split(sep).join("/");
@@ -39478,14 +39478,12 @@ function scanImports(file2, source) {
39478
39478
  const fatal = result4.errors.find((e) => e.severity === "Error");
39479
39479
  if (fatal) return { imports: [], error: { message: fatal.message, at: fatal.labels[0]?.start ?? 0 } };
39480
39480
  const imports = [];
39481
- for (const imp of result4.module.staticImports) {
39482
- if (imp.entries.length && imp.entries.every((e) => e.isType)) continue;
39483
- imports.push({ spec: imp.moduleRequest.value, at: imp.moduleRequest.start, kind: "import-statement" });
39484
- }
39485
- for (const exp of result4.module.staticExports) {
39486
- const from = exp.entries.filter((e) => e.moduleRequest && !e.isType);
39487
- const first = from[0]?.moduleRequest;
39488
- if (first) imports.push({ spec: first.value, at: first.start, kind: "import-statement" });
39481
+ for (const statement of result4.program.body) {
39482
+ if (statement.type === "ImportDeclaration" && statement.importKind !== "type") {
39483
+ imports.push({ spec: statement.source.value, at: statement.source.start, kind: "import-statement" });
39484
+ } else if ((statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") && statement.exportKind !== "type" && statement.source) {
39485
+ imports.push({ spec: statement.source.value, at: statement.source.start, kind: "import-statement" });
39486
+ }
39489
39487
  }
39490
39488
  for (const imp of result4.module.dynamicImports) {
39491
39489
  const literal = /^\s*(["'`])(.*)\1\s*$/s.exec(source.slice(imp.moduleRequest.start, imp.moduleRequest.end));
@@ -39493,10 +39491,19 @@ function scanImports(file2, source) {
39493
39491
  }
39494
39492
  return { imports: imports.sort((a, b) => (a.at ?? 0) - (b.at ?? 0)) };
39495
39493
  }
39494
+ var EXTENSIONS = [".ts", ".mts", ".js", ".mjs", ".tsx"];
39495
+ function importCandidates(base) {
39496
+ const stem = /\.m?js$/.test(base) ? base.replace(/\.m?js$/, "") : void 0;
39497
+ return [
39498
+ base,
39499
+ ...EXTENSIONS.map((ext) => base + ext),
39500
+ ...stem === void 0 ? [] : [".ts", ".mts", ".tsx"].map((ext) => stem + ext),
39501
+ ...EXTENSIONS.map((ext) => join5(base, "index" + ext))
39502
+ ].filter((candidate) => EXTENSIONS.some((ext) => candidate.endsWith(ext)));
39503
+ }
39496
39504
  var resolveRelative = Effect_exports.fnUntraced(function* (fromFile, spec) {
39497
39505
  const fs = yield* FileSystem_exports.FileSystem;
39498
- const base = resolve8(dirname2(fromFile), spec);
39499
- for (const candidate of [base, base + ".ts", join5(base, "index.ts")]) {
39506
+ for (const candidate of importCandidates(resolve8(dirname2(fromFile), spec))) {
39500
39507
  const info2 = yield* fs.stat(candidate).pipe(Effect_exports.option);
39501
39508
  if (info2._tag === "Some" && info2.value.type === "File") return candidate;
39502
39509
  }
@@ -39573,6 +39580,7 @@ function clientPlugin(root, sdk, rejected) {
39573
39580
  setup(build3) {
39574
39581
  build3.onResolve({ filter: /^[^./]/ }, async (args2) => {
39575
39582
  if (args2.pluginData === "eraspace-sdk") return void 0;
39583
+ if (args2.kind === "entry-point" || isAbsolute(args2.path)) return void 0;
39576
39584
  const spec = args2.path;
39577
39585
  const fromCapsule = !args2.importer || inCapsule(args2.importer);
39578
39586
  if (fromCapsule) {
@@ -39778,6 +39786,14 @@ var requireTarget = Effect_exports.fnUntraced(function* (config2, explicit) {
39778
39786
  );
39779
39787
  return { ref, claim: claim2 };
39780
39788
  });
39789
+ var confirmedDeletes = Effect_exports.fnUntraced(function* (tables) {
39790
+ const out = {};
39791
+ for (const [table2, rows] of Object.entries(tables ?? {})) {
39792
+ if (!/^\d+$/.test(rows)) return yield* fail12(`--delete-table ${table2}=${rows}: the row count must be a whole number, e.g. --delete-table ${table2}=12`);
39793
+ out[table2] = Number(rows);
39794
+ }
39795
+ return out;
39796
+ });
39781
39797
  var withoutEnv = Effect_exports.fnUntraced(function* (payload) {
39782
39798
  if (Object.keys(payload.env).length) {
39783
39799
  yield* Console_exports.warn(
@@ -39792,19 +39808,10 @@ var okDeployed = ok(Deployed);
39792
39808
  var requestedName = (config2, opts) => opts.name !== void 0 ? { name: opts.name } : { name: config2.name ?? toAppName(basename(config2.root)), uniqueName: true };
39793
39809
  var createApp = Effect_exports.fnUntraced(function* (config2, payload, extra, opts, signedIn) {
39794
39810
  const naming = requestedName(config2, opts);
39795
- const create = (body) => api(config2, "POST", "/_api/apps", {
39796
- body: { ...payload, ...extra, ...body },
39811
+ const response = yield* api(config2, "POST", "/_api/apps", {
39812
+ body: { ...payload, ...extra, ...naming },
39797
39813
  timeout: DEPLOY_TIMEOUT
39798
39814
  });
39799
- let response = yield* create(naming);
39800
- if (response.status === 401 && !signedIn && naming.uniqueName) {
39801
- yield* Console_exports.warn(
39802
- c.yellow(
39803
- `This platform names apps only for signed-in developers, so this app gets an id. Run eraspace auth login and eraspace claim to name it ${naming.name}.`
39804
- )
39805
- );
39806
- response = yield* create({});
39807
- }
39808
39815
  if (response.status === 409 && !naming.uniqueName) {
39809
39816
  return yield* fail12(
39810
39817
  signedIn ? `The name ${naming.name} is taken. Pick another with --name or in eraspace.json; if it is your app, deploy to it with --app ${naming.name}.` : `The name ${naming.name} is taken. Pick another with --name.`
@@ -39827,7 +39834,11 @@ var deploy = Effect_exports.fnUntraced(function* (config2, opts = {}) {
39827
39834
  const build3 = yield* buildCapsule(config2.root);
39828
39835
  if (!build3.ok || !build3.payload)
39829
39836
  return yield* fail12(yield* formatErrors(build3.errors, config2.root));
39830
- const extra = opts.publicInspect ? { publicInspection: true } : {};
39837
+ const deleteTables = yield* confirmedDeletes(opts.deleteTables);
39838
+ const extra = {
39839
+ ...opts.publicInspect ? { publicInspection: true } : {},
39840
+ ...Object.keys(deleteTables).length ? { deleteTables } : {}
39841
+ };
39831
39842
  const signedIn = (yield* bearerFor(config2.origin)) !== void 0;
39832
39843
  const t = yield* target(config2);
39833
39844
  let result4;
@@ -40134,7 +40145,7 @@ import { basename as basename2, join as join7, relative as relative3, resolve as
40134
40145
  var TEMPLATES = ["chat", "todo", "poll"];
40135
40146
  var agents = (cli) => `# eraspace capsule: agent instructions
40136
40147
 
40137
- This directory is one eraspace app ("capsule"): a TypeScript server compiled to BEAM bytecode, a Preact client, a
40148
+ This directory is one eraspace app ("capsule"): a TypeScript server running in V8, a Preact client, a
40138
40149
  Postgres-backed database with live queries, and hosting, all through the \`eraspace\` CLI. Treat this directory as the
40139
40150
  whole app. Build the requested app by editing the starter files, deploy it, verify it, and report the URL.
40140
40151
 
@@ -40217,7 +40228,9 @@ export default capsule({
40217
40228
  - \`ctx.log.info(msg, data?)\` (also debug/warn/error) writes to \`eraspace logs\`.
40218
40229
  - Endpoints receive \`req\` with \`method, path, url, headers.get(), query.get(), json(), text()\` and return
40219
40230
  \`json(value)\`, \`text(string)\`, \`empty()\`, \`redirect(url)\` or a plain value (sent as JSON).
40220
- - Removing a table that still holds rows is refused on deploy. Keep it in the schema.
40231
+ - Removing a populated table needs its row count confirmed: \`${cli} deploy --delete-table <table>=<rows>\` (the
40232
+ refused deploy prints the exact flag). Deletion runs in background batches.
40233
+ - Changing a field's type or making it required fails the deploy while stored rows do not match; migrate them first.
40221
40234
 
40222
40235
  ## Client API (\`eraspace/client\`)
40223
40236
 
@@ -40251,28 +40264,55 @@ export default function App() {
40251
40264
  - Realtime rooms, not stored: \`const room = client.useRoom(name, meta)\` gives \`room.presence\`,
40252
40265
  \`room.broadcast(event, payload)\` and \`room.on(event, cb)\` (call \`on\` inside \`useEffect\` and return its
40253
40266
  cleanup). \`client.usePresence(name, meta)\` returns just the members.
40254
- - Files: \`await client.storage.upload(file)\` returns \`{ key }\`; \`client.storage.url(key)\`; \`client.storage.delete(key)\`.
40255
- Up to 5 MiB per file.
40267
+ - Files: see "File uploads" below.
40256
40268
  - Pages: \`Router\`, \`Route\`, \`Link\`, \`useLocation\`, \`useNavigate\`, \`useParams\` from \`eraspace/client\`.
40257
40269
  - Client imports are limited to \`eraspace/client\`, \`preact\`, \`preact/hooks\`, \`@preact/signals\` and relative files.
40258
40270
 
40259
- ## TypeScript subset on the server
40271
+ ## File uploads
40260
40272
 
40261
- Server code is compiled to BEAM, not run in Node. It supports modern TypeScript (classes, closures, async/await,
40262
- destructuring, spread, template literals, Map, Set, JSON, Math, Date, URL, RegExp, fetch) with these limits:
40273
+ Files go to the app's bucket, not the database. Save the returned \`FileRef\` in a row, then preview it.
40263
40274
 
40264
- - No npm packages, Node built-ins (\`node:*\`, \`fs\`, \`process\`, \`Buffer\`) or dynamic \`import()\`. Do not install
40265
- app dependencies.
40266
- - Rejected at compile time: \`eval\`, \`new Function\`, Proxy, Reflect, generators, getters/setters, private
40267
- \`#fields\`, decorators, enums, namespaces, BigInt, symbols, labeled statements, \`for await\`, static blocks and
40268
- named function expressions (\`const f = function g() {}\`; use arrow functions or function declarations).
40269
- - Do not declare regex literals at module top level; create them inside the function that uses them.
40270
- - Do not use constructor parameter properties (\`constructor(private x: number)\`). Declare the field and assign
40271
- \`this.x = x\` in the constructor.
40272
- - Module-level variables are reset for every handler call. Persist state in the database.
40273
- - \`await\` blocks; there is no real concurrency inside a handler. Handlers time out after about a second and have
40274
- a memory limit, so keep loops and reads bounded.
40275
- - No Intl or \`toLocaleString\` formatting on the server. Format dates and numbers in the client.
40275
+ \`\`\`tsx
40276
+ // server/index.ts: declare the file in the schema and copy its fields in the mutation (extra fields are refused).
40277
+ photos: table({ caption: string(), file: object({ key: string(), name: string(), contentType: string(), size: number() }) }),
40278
+ addPhoto: mutation(async (ctx, caption: string, file: { key: string; name: string; contentType: string; size: number }) => {
40279
+ if (!/^[0-9a-f-]{36}$/.test(String(file?.key))) throw new Error("Upload the photo first.");
40280
+ return ctx.db.photos.insert({ caption, file: { key: file.key, name: file.name, contentType: file.contentType, size: file.size } });
40281
+ }),
40282
+
40283
+ // client/index.tsx
40284
+ import { createClient, FileUpload, FilePreview } from "eraspace/client";
40285
+ const addPhoto = client.useMutation("addPhoto");
40286
+ <FileUpload client={client} accept="image/*" onUpload={(file) => addPhoto(caption, file)} />
40287
+ {photos?.map((p) => <FilePreview key={p.id} client={client} file={p.file} />)}
40288
+ \`\`\`
40289
+
40290
+ - \`<FileUpload>\` is a drop zone and file picker with progress and error text. Props: \`accept\`, \`multiple\`,
40291
+ \`maxBytes\`, \`disabled\`, \`class\` (replaces its default style; \`data-dragging\` is set during a drag) and
40292
+ children for the label. \`onUpload(file)\` runs once per stored file; if it throws, the file is deleted again.
40293
+ - \`<FilePreview file={fileRef or key}>\` shows images, video and audio inline, plain text in a frame, and other
40294
+ types (PDF, zip...) as a download card. \`caption={false}\` hides the name/size line; \`class\` replaces its style.
40295
+ Pass the saved \`FileRef\`: a bare key costs a metadata request per preview.
40296
+ - Custom UI: \`const { upload, uploading, progress, error } = client.useUpload({ accept, maxBytes, onUpload })\`,
40297
+ then \`upload(input.files)\`. Lower level: \`client.storage.upload(blob, { onProgress })\`, \`.url(key)\`,
40298
+ \`.info(key)\` and \`.delete(key)\`.
40299
+ - \`accept\` and \`maxBytes\` are checked in the browser only; the server stores any type up to its limit.
40300
+ - Up to 5 MiB per file and 100 MiB per app. Anyone with a key can read or delete that file; never upload secrets.
40301
+
40302
+ ## Server execution
40303
+
40304
+ Server TypeScript is transformed with oxc and runs as ES modules in V8. JavaScript language features such as
40305
+ classes, closures, async/await, generators, getters, private fields, Proxy, Reflect and BigInt use V8 semantics.
40306
+ Values crossing the database or RPC boundary must be JSON-compatible.
40307
+
40308
+ - Imports support relative deployed files and \`eraspace/server\`. npm packages, Node built-ins and dynamic
40309
+ \`import()\` are unavailable. Apps need no installed server dependencies.
40310
+ - The host supplies \`fetch\`, \`URL\`, \`Headers\`, \`Request\`, \`Response\`, \`crypto.randomUUID\`,
40311
+ \`TextEncoder\`, \`TextDecoder\`, timers and \`console\`.
40312
+ - Persist state in the database. Module state is not durable and resets when an app unloads or a worker restarts.
40313
+ - Await database calls. Mutations run through one writer per app and reply after their Postgres commit.
40314
+ - Handlers have CPU, wall-clock and heap limits. Keep loops and reads bounded.
40315
+ - Outbound fetch is available to claimed apps in actions and read-only endpoints. Private addresses are blocked.
40276
40316
 
40277
40317
  ## Deploys without an account
40278
40318
 
@@ -40410,6 +40450,12 @@ var deployFlags = {
40410
40450
  publicInspect: Flag_exports.Boolean("public-inspect").pipe(Flag_exports.withDefault(false), Flag_exports.withDescription("Let anyone read this app's inspection data and logs"))
40411
40451
  };
40412
40452
  var deployOptions = (input) => ({ name: value3(input.name), publicInspect: input.publicInspect });
40453
+ var deleteTableFlag = {
40454
+ deleteTable: Flag_exports.KeyValuePair("delete-table").pipe(
40455
+ Flag_exports.withDescription("Delete a table removed from the schema, confirming its current row count: --delete-table <table>=<rows> (repeatable)"),
40456
+ Flag_exports.optional
40457
+ )
40458
+ };
40413
40459
  var browserFlag = {
40414
40460
  browser: Flag_exports.Boolean("browser").pipe(Flag_exports.withDefault(true), Flag_exports.withDescription("Open the sign-in page in a browser; --no-browser only prints the URL"))
40415
40461
  };
@@ -40431,8 +40477,13 @@ var initCommand = Command_exports.make("init", { template: templateFlag }, ({ te
40431
40477
  var typecheckCommand = Command_exports.make("typecheck", {}, () => Effect_exports.flatMap(config, (c2) => typecheck(c2.root))).pipe(
40432
40478
  Command_exports.withDescription("Typecheck server/, client/ and shared/ (no tsconfig.json or node_modules needed)")
40433
40479
  );
40434
- var deployCommand = Command_exports.make("deploy", deployFlags, (input) => Effect_exports.flatMap(config, (c2) => deploy(c2, deployOptions(input)))).pipe(
40435
- Command_exports.withDescription("Build and deploy. Without a login this creates a temporary unclaimed app.")
40480
+ var deployCommand = Command_exports.make(
40481
+ "deploy",
40482
+ { ...deployFlags, ...deleteTableFlag },
40483
+ (input) => Effect_exports.flatMap(config, (c2) => deploy(c2, { ...deployOptions(input), deleteTables: value3(input.deleteTable) }))
40484
+ ).pipe(
40485
+ Command_exports.withDescription("Build and deploy. Without a login this creates a temporary unclaimed app."),
40486
+ Command_exports.withExamples([{ command: "eraspace deploy --delete-table drafts=12", description: "Deploy a schema without drafts, deleting its 12 rows" }])
40436
40487
  );
40437
40488
  var devCommand = Command_exports.make("dev", deployFlags, (input) => Effect_exports.flatMap(config, (c2) => dev(c2, deployOptions(input)))).pipe(
40438
40489
  Command_exports.withDescription("Deploy, then redeploy whenever server/, client/, shared/ or .env.eraspace.server change")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eraspace",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "eraspace: build, deploy and manage eraspace capsules",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -8,7 +8,7 @@
8
8
  "cli",
9
9
  "realtime",
10
10
  "preact",
11
- "beam"
11
+ "v8"
12
12
  ],
13
13
  "license": "MIT",
14
14
  "bin": {
@@ -24,10 +24,8 @@
24
24
  "@preact/signals": "2.11.3",
25
25
  "@tailwindcss/node": "4.3.3",
26
26
  "@tailwindcss/oxide": "4.3.3",
27
- "@types/phoenix": "1.6.7",
28
27
  "esbuild": "0.28.2",
29
28
  "oxc-parser": "0.154.0",
30
- "phoenix": "1.8.15",
31
29
  "preact": "11.0.1",
32
30
  "preact-iso": "2.12.2",
33
31
  "tailwindcss": "4.3.3",
@@ -1,11 +1,13 @@
1
1
  import { computed, type ReadonlySignal } from "@preact/signals";
2
- import { useEffect, useMemo, useRef } from "preact/hooks";
2
+ import { useEffect, useMemo, useRef, useState } from "preact/hooks";
3
3
  import { createStorageClient } from "./storage.ts";
4
+ import { useUpload, type UseUploadOptions } from "./files.ts";
4
5
  import type { ClientOptions } from "./config.ts";
5
6
  import { subKey, stableStringify } from "./sub-key.ts";
6
7
  import type { Subscription } from "./subscription.ts";
7
8
  import type { RoomConn } from "./room.ts";
8
9
  import type { EraspaceClient, Room } from "./types.ts";
10
+ import { EraspaceError } from "./errors.ts";
9
11
  import { ClientImpl } from "./connection.ts";
10
12
  import { Pager } from "./pagination.ts";
11
13
 
@@ -39,7 +41,8 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
39
41
  return useMemo(() => computed(() => {
40
42
  const err = sub.error.value;
41
43
  const value: unknown = sub.value.value;
42
- if (err && value === undefined) throw err;
44
+ // Retried failures keep showing the last value; a gone app cannot recover.
45
+ if (err && (value === undefined || err.name === "AppGone")) throw err;
43
46
  return value;
44
47
  }), [sub]);
45
48
  }
@@ -61,7 +64,7 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
61
64
  }
62
65
 
63
66
  function useAction(name: string) {
64
- return useMemo(() => (...args: unknown[]) => get().call(name, args), [name]);
67
+ return useMemo(() => (...args: unknown[]) => get().call(name, args, "action"), [name]);
65
68
  }
66
69
 
67
70
  function usePaginatedQuery(name: string, args: unknown[], opts?: { initialNumItems?: number }) {
@@ -85,8 +88,17 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
85
88
 
86
89
  function useRoomConn(room: string, meta: Record<string, unknown> | undefined): RoomConn {
87
90
  const c = get();
88
- const r = useMemo(() => c.acquireRoom(room), [room]);
89
- useEffect(() => () => c.releaseRoom(r), [r]);
91
+ const [generation, renew] = useState(0);
92
+ // Acquiring during render holds nothing: only a committed effect retains the room.
93
+ const r = useMemo(() => c.acquireRoom(room), [room, generation]);
94
+ useEffect(() => {
95
+ if (!c.retainRoom(r)) {
96
+ // Dropped between render and commit.
97
+ renew((n) => n + 1);
98
+ return;
99
+ }
100
+ return () => c.releaseRoom(r);
101
+ }, [r]);
90
102
  const metaKey = meta === undefined ? undefined : stableStringify(meta);
91
103
  useEffect(() => {
92
104
  if (meta !== undefined) r.setMeta(meta);
@@ -104,8 +116,9 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
104
116
  presence: r.members.value,
105
117
  self: r.self.value,
106
118
  state: r.state.value,
119
+ error: r.error.value,
107
120
  broadcast: (event: string, payload?: unknown) => {
108
- r.channel.push("broadcast", { event, payload: payload ?? null });
121
+ void r.channel.push("broadcast", { event, payload: payload ?? null }).catch(error => { r.error.value = EraspaceError.from(error); });
109
122
  },
110
123
  on: (event: string, cb: (payload: any, from: string | undefined) => void) => {
111
124
  let set = r.listeners.get(event);
@@ -114,14 +127,15 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
114
127
  return () => set!.delete(cb);
115
128
  },
116
129
  setState: (key: string, value: unknown) => {
117
- r.state.value = { ...r.state.peek(), [key]: value };
118
- r.channel.push("state:set", { key, value: value ?? null });
130
+ r.setState(key, value);
119
131
  },
120
132
  };
121
133
  }
122
134
 
135
+ const storage = createStorageClient(() => get().base);
123
136
  const client = {
124
- storage: createStorageClient(() => get().base),
137
+ storage,
138
+ useUpload: (opts?: UseUploadOptions) => useUpload(storage, opts),
125
139
  useQuery,
126
140
  useQuerySignal,
127
141
  useQuerySelector,
@@ -131,8 +145,9 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
131
145
  usePresence,
132
146
  useRoom,
133
147
  query: (name: string, ...args: unknown[]) => get().call(name, args),
134
- mutation: (name: string, ...args: unknown[]) => get().call(name, args),
135
- action: (name: string, ...args: unknown[]) => get().call(name, args),
148
+ mutation: (name: string, ...args: unknown[]) => get().mutate(name, args),
149
+ retryMutation: (name: string, args: unknown[], requestKey: string) => get().retryMutation(name, args, requestKey),
150
+ action: (name: string, ...args: unknown[]) => get().call(name, args, "action"),
136
151
  subscribe: (name: string, args: unknown[], onValue: Fn, onError?: Fn) => get().subscribe(name, args, onValue, onError),
137
152
  get app() {
138
153
  return get().app;
@@ -1,3 +1,5 @@
1
+ import type { SocketOptions, WebSocketConstructor } from "./transport.ts";
2
+
1
3
  /** `window.__ERASPACE__`, injected by the platform's HTML shell (SPEC §6). */
2
4
  export interface EraspaceConfig {
3
5
  app: string;
@@ -12,7 +14,7 @@ export interface ClientOptions {
12
14
  socket?: string;
13
15
  base?: string;
14
16
  version?: number;
15
- /** Reload the page when the app is redeployed (after re-subscribing). Default `false`. */
17
+ /** Reload the page when the app is redeployed. Default `false`. */
16
18
  reloadOnDeploy?: boolean;
17
19
  /** Push timeout in ms. Default 15000. */
18
20
  timeout?: number;
@@ -21,9 +23,9 @@ export interface ClientOptions {
21
23
  /** Delay before refreshing a subscription still waiting for a mutation commit. Default 2000 ms. */
22
24
  optimisticTimeoutMs?: number;
23
25
  /** WebSocket constructor (tests, non-browser runtimes). */
24
- transport?: unknown;
25
- /** Extra Phoenix socket options. */
26
- socketOptions?: Record<string, unknown>;
26
+ transport?: WebSocketConstructor;
27
+ /** Reconnect and rejoin delay overrides. */
28
+ socketOptions?: Pick<SocketOptions, "reconnectAfterMs" | "rejoinAfterMs">;
27
29
  }
28
30
 
29
31
  declare global {